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:
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