feat: file imports

This commit is contained in:
rhunk 2024-05-29 21:17:11 +02:00
parent 7f5a10cce5
commit 7d5c053f21
9 changed files with 333 additions and 25 deletions

View File

@ -9,6 +9,7 @@ import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
import java.io.File
import java.io.OutputStream
class LocalFileHandle(
@ -39,7 +40,7 @@ class AssetFileHandle(
return runCatching {
context.androidContext.assets.open(assetPath).toParcelFileDescriptor(context.coroutineScope)
}.onFailure {
AbstractLogger.directError("Failed to open asset handle: ${it.message}", it)
context.log.error("Failed to open asset handle: ${it.message}", it)
}.getOrNull()
}
}
@ -48,6 +49,10 @@ class AssetFileHandle(
class RemoteFileHandleManager(
private val context: RemoteSideContext
): FileHandleManager.Stub() {
private val userImportFolder = File(context.androidContext.filesDir, "user_imports").apply {
mkdirs()
}
override fun getFileHandle(scope: String, name: String): FileHandle? {
val fileHandleScope = FileHandleScope.fromValue(scope) ?: run {
context.log.error("invalid file handle scope: $scope", "FileHandleManager")
@ -81,7 +86,43 @@ class RemoteFileHandleManager(
"lang/$foundLocale.json"
)
}
FileHandleScope.USER_IMPORT -> {
return LocalFileHandle(
File(userImportFolder, name.substringAfterLast("/"))
)
}
else -> return null
}
}
fun getStoredFiles(): List<File> {
return userImportFolder.listFiles()?.toList()?.sortedBy { -it.lastModified() } ?: emptyList()
}
fun getFileInfo(name: String): Pair<Long, Long>? {
return runCatching {
val file = File(userImportFolder, name)
file.length() to file.lastModified()
}.onFailure {
context.log.error("Failed to get file info: ${it.message}", it)
}.getOrNull()
}
fun importFile(name: String, block: OutputStream.() -> Unit): Boolean {
return runCatching {
val file = File(userImportFolder, name)
file.outputStream().use(block)
true
}.onFailure {
context.log.error("Failed to import file: ${it.message}", it)
}.getOrDefault(false)
}
fun deleteFile(name: String): Boolean {
return runCatching {
File(userImportFolder, name).delete()
}.onFailure {
context.log.error("Failed to delete file: ${it.message}", it)
}.isSuccess
}
}

View File

@ -15,6 +15,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
@ -58,6 +59,7 @@ class Routes(
val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home)
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)

View File

@ -0,0 +1,158 @@
package me.rhunk.snapenhance.ui.manager.pages
import android.net.Uri
import android.text.format.Formatter
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.openFile
import java.text.DateFormat
class FileImportsRoot: Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val reloadDispatcher = AsyncUpdateDispatcher()
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Row {
ExtendedFloatingActionButton(
icon = {
Icon(Icons.Default.Upload, contentDescription = null)
},
text = {
Text(translation["import_file_button"])
},
onClick = {
context.coroutineScope.launch {
activityLauncherHelper.openFile { filePath ->
val fileUri = Uri.parse(filePath)
runCatching {
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
if (!file.exists()) {
context.shortToast(translation["file_not_found"])
return@openFile
}
context.fileHandleManager.importFile(file.name!!) {
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
inputStream.copyTo(this)
}
}
}
}.onFailure {
context.log.error("Failed to import file", it)
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
}.onSuccess {
context.shortToast(translation["file_imported"])
coroutineScope.launch {
reloadDispatcher.dispatch()
}
}
}
}
})
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.fileHandleManager.getStoredFiles()
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
item {
if (files.isEmpty()) {
Text(
text = translation["no_files_hint"],
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 18.sp,
fontWeight = FontWeight.Light
)
}
}
items(files, key = { it }) { file ->
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
val fileInfo by rememberAsyncMutableState(defaultValue = null) {
context.fileHandleManager.getFileInfo(file.name)
}
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
Column(
modifier = Modifier.weight(1f).padding(8.dp),
) {
Text(text = file.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 20.sp)
fileInfo?.let { (size, lastModified) ->
Text(text = "${Formatter.formatFileSize(context.androidContext, size)} - ${DateFormat.getDateTimeInstance().format(lastModified)}", lineHeight = 15.sp)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
IconButton(onClick = {
context.coroutineScope.launch {
if (context.fileHandleManager.deleteFile(file.name)) {
files.remove(file)
} else {
context.shortToast(translation["file_delete_failed"])
}
}
}) {
Icon(Icons.Default.DeleteOutline, contentDescription = null)
}
}
}
}
}
item {
Spacer(modifier = Modifier.height(100.dp))
}
}
}
}

View File

@ -14,10 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -40,6 +37,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.config.*
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.*
@ -153,6 +151,71 @@ class FeaturesRoot : Routes.Route() {
val propertyValue = property.value
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
registerDialogOnClickCallback()
dialogComposable = {
val files = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.fileHandleManager.getStoredFiles()
}
var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }?.name) }
Card(
shape = MaterialTheme.shapes.large,
modifier = Modifier
.fillMaxWidth(),
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(4.dp),
) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"],
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
if (files.isEmpty()) {
Text(
text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"],
fontSize = 16.sp,
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
items(files, key = { it.name }) { file ->
Row(
modifier = Modifier.clickable {
selectedFile = if (selectedFile == file.name) null else file.name
propertyValue.setAny(selectedFile)
}.padding(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
Text(
text = file.name,
modifier = Modifier
.padding(3.dp)
.weight(1f),
fontSize = 14.sp,
lineHeight = 16.sp
)
if (selectedFile == file.name) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp))
}
}
}
}
}
}
Icon(Icons.Filled.AttachFile, contentDescription = null)
return
}
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
IconButton(onClick = registerClickCallback {
activityLauncher {

View File

@ -8,11 +8,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PersonSearch
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -61,6 +57,9 @@ class HomeRoot : Routes.Route() {
private val cards by lazy {
mapOf(
("File Imports" to Icons.Default.FolderOpen) to {
routes.fileImports.navigateReset()
},
("Friend Tracker" to Icons.Default.PersonSearch) to {
routes.friendTracker.navigateReset()
},

View File

@ -35,6 +35,7 @@
"logged_stories": "Logged Stories",
"friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule",
"file_imports": "File Imports",
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
@ -131,6 +132,14 @@
"message_parse_failed": "Failed to parse message",
"unknown_sender": "Unknown Sender",
"download_attachment_failed_toast": "Failed to download attachment"
},
"file_imports": {
"import_file_button": "Import File",
"file_not_found": "File not found",
"file_import_failed": "Failed to import file: {error}",
"file_imported": "File imported successfully",
"file_delete_failed": "Failed to delete file",
"no_files_hint": "Here you can import files for use in Snapchat. Press the button below to import a file."
}
},
"dialogs": {
@ -153,6 +162,10 @@
"messaging_action": {
"title": "Choose content types to process",
"select_all_button": "Select All"
},
"file_imports": {
"no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section",
"settings_select_file_hint": "Select an imported file"
}
}
},

View File

@ -12,7 +12,8 @@ enum class FileHandleScope(
val key: String
) {
INTERNAL("internal"),
LOCALE("locale");
LOCALE("locale"),
USER_IMPORT("user_import");
companion object {
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }

View File

@ -12,24 +12,26 @@ data class PropertyPair<T>(
}
enum class FeatureNotice(
val id: Int,
val key: String
) {
UNSTABLE(0b0001, "unstable"),
BAN_RISK(0b0010, "ban_risk"),
INTERNAL_BEHAVIOR(0b0100, "internal_behavior"),
REQUIRE_NATIVE_HOOKS(0b1000, "require_native_hooks"),
UNSTABLE("unstable"),
BAN_RISK("ban_risk"),
INTERNAL_BEHAVIOR("internal_behavior"),
REQUIRE_NATIVE_HOOKS("require_native_hooks");
val id get() = 1 shl ordinal
}
enum class ConfigFlag(
val id: Int
) {
NO_TRANSLATE(0b000001),
HIDDEN(0b000010),
FOLDER(0b000100),
NO_DISABLE_KEY(0b001000),
REQUIRE_RESTART(0b010000),
REQUIRE_CLEAN_CACHE(0b100000)
enum class ConfigFlag {
NO_TRANSLATE,
HIDDEN,
FOLDER,
USER_IMPORT,
NO_DISABLE_KEY,
REQUIRE_RESTART,
REQUIRE_CLEAN_CACHE;
val id = 1 shl ordinal
}
class ConfigParams(

View File

@ -0,0 +1,29 @@
package me.rhunk.snapenhance.core.util.ktx
import android.os.Build
import android.os.ParcelFileDescriptor
import me.rhunk.snapenhance.bridge.storage.FileHandleManager
import me.rhunk.snapenhance.common.bridge.FileHandleScope
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.core.ModContext
import java.io.FileOutputStream
import kotlin.math.absoluteValue
fun FileHandleManager.getFileHandleLocalPath(
context: ModContext,
scope: FileHandleScope,
name: String,
fileUniqueIdentifier: String,
): String? {
return getFileHandle(scope.key, name)?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
val cacheFile = context.androidContext.cacheDir.resolve((fileUniqueIdentifier + Build.FINGERPRINT).longHashCode().absoluteValue.toString(16))
if (!cacheFile.exists() || pfd.statSize != cacheFile.length()) {
FileOutputStream(cacheFile).use { output ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
input.copyTo(output)
}
}
}
cacheFile.absolutePath
}
}