commit 7d5c053f21192cbe8a3a040cc7cb0ef44a25198c
parent 7f5a10cce507bfeecab7390cbe3b0b2d5dcde3a3
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed, 29 May 2024 21:17:11 +0200

feat: file imports

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt | 43++++++++++++++++++++++++++++++++++++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt | 2++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt | 9++++-----
Mcommon/src/main/assets/lang/en_US.json | 13+++++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt | 3++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt | 30++++++++++++++++--------------
Acore/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt | 30++++++++++++++++++++++++++++++
9 files changed, 335 insertions(+), 25 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -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) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt @@ -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)) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt @@ -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 { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt @@ -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() }, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -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" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt @@ -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 } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -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( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt @@ -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 + } +}+ \ No newline at end of file