mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-29 13:00:17 +02:00
feat: file imports
This commit is contained in:
parent
7f5a10cce5
commit
7d5c053f21
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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()
|
||||
},
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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 }
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user