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.logger.AbstractLogger
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
import java.io.File import java.io.File
import java.io.OutputStream
class LocalFileHandle( class LocalFileHandle(
@ -39,7 +40,7 @@ class AssetFileHandle(
return runCatching { return runCatching {
context.androidContext.assets.open(assetPath).toParcelFileDescriptor(context.coroutineScope) context.androidContext.assets.open(assetPath).toParcelFileDescriptor(context.coroutineScope)
}.onFailure { }.onFailure {
AbstractLogger.directError("Failed to open asset handle: ${it.message}", it) context.log.error("Failed to open asset handle: ${it.message}", it)
}.getOrNull() }.getOrNull()
} }
} }
@ -48,6 +49,10 @@ class AssetFileHandle(
class RemoteFileHandleManager( class RemoteFileHandleManager(
private val context: RemoteSideContext private val context: RemoteSideContext
): FileHandleManager.Stub() { ): FileHandleManager.Stub() {
private val userImportFolder = File(context.androidContext.filesDir, "user_imports").apply {
mkdirs()
}
override fun getFileHandle(scope: String, name: String): FileHandle? { override fun getFileHandle(scope: String, name: String): FileHandle? {
val fileHandleScope = FileHandleScope.fromValue(scope) ?: run { val fileHandleScope = FileHandleScope.fromValue(scope) ?: run {
context.log.error("invalid file handle scope: $scope", "FileHandleManager") context.log.error("invalid file handle scope: $scope", "FileHandleManager")
@ -81,7 +86,43 @@ class RemoteFileHandleManager(
"lang/$foundLocale.json" "lang/$foundLocale.json"
) )
} }
FileHandleScope.USER_IMPORT -> {
return LocalFileHandle(
File(userImportFolder, name.substringAfterLast("/"))
)
}
else -> return null 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.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext 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.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot 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 friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home)
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule()) 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 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 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) 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.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,6 +37,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.config.* 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.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.* import me.rhunk.snapenhance.ui.util.*
@ -153,6 +151,71 @@ class FeaturesRoot : Routes.Route() {
val propertyValue = property.value 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)) { if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
IconButton(onClick = registerClickCallback { IconButton(onClick = registerClickCallback {
activityLauncher { activityLauncher {

View File

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

View File

@ -35,6 +35,7 @@
"logged_stories": "Logged Stories", "logged_stories": "Logged Stories",
"friend_tracker": "Friend Tracker", "friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule", "edit_rule": "Edit Rule",
"file_imports": "File Imports",
"social": "Social", "social": "Social",
"manage_scope": "Manage Scope", "manage_scope": "Manage Scope",
"messaging_preview": "Preview", "messaging_preview": "Preview",
@ -131,6 +132,14 @@
"message_parse_failed": "Failed to parse message", "message_parse_failed": "Failed to parse message",
"unknown_sender": "Unknown Sender", "unknown_sender": "Unknown Sender",
"download_attachment_failed_toast": "Failed to download attachment" "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": { "dialogs": {
@ -153,6 +162,10 @@
"messaging_action": { "messaging_action": {
"title": "Choose content types to process", "title": "Choose content types to process",
"select_all_button": "Select All" "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 val key: String
) { ) {
INTERNAL("internal"), INTERNAL("internal"),
LOCALE("locale"); LOCALE("locale"),
USER_IMPORT("user_import");
companion object { companion object {
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name } fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }

View File

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