commit 12ad30ffd804e86aa4f6b12726be35d4efc7a8a1 parent fb3d7af9cc101bc96d809ffde288f54315eb34d4 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 17 Jul 2024 12:55:25 +0200 Merge branch 'refs/heads/refactor_ex' into refactor Diffstat:
43 files changed, 2300 insertions(+), 491 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt @@ -8,10 +8,28 @@ import me.rhunk.snapenhance.common.bridge.InternalFileHandleType import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor +import me.rhunk.snapenhance.storage.getEnabledThemesContent import java.io.File import java.io.OutputStream +class ByteArrayFileHandle( + private val context: RemoteSideContext, + private val data: ByteArray +): FileHandle.Stub() { + override fun exists() = true + override fun create() = false + override fun delete() = false + + override fun open(mode: Int): ParcelFileDescriptor? { + return runCatching { + data.inputStream().toParcelFileDescriptor(context.coroutineScope) + }.onFailure { + context.log.error("Failed to open byte array file handle: ${it.message}", it) + }.getOrNull() + } +} + class LocalFileHandle( private val file: File ): FileHandle.Stub() { @@ -97,6 +115,12 @@ class RemoteFileHandleManager( "composer/${name.substringAfterLast("/")}" ) } + FileHandleScope.THEME -> { + return ByteArrayFileHandle( + context, + context.gson.toJson(context.database.getEnabledThemesContent()).toByteArray(Charsets.UTF_8) + ) + } else -> return null } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSharedLibraryManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSharedLibraryManager.kt @@ -0,0 +1,80 @@ +package me.rhunk.snapenhance + +import android.os.Build +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +class RemoteSharedLibraryManager( + private val remoteSideContext: RemoteSideContext +) { + private val okHttpClient = OkHttpClient() + + private fun getVersion(): String? { + return runCatching { + okHttpClient.newCall( + Request.Builder() + .url("https://raw.githubusercontent.com/SnapEnhance/resources/main/sif/version") + .build() + ).execute().use { response -> + if (!response.isSuccessful) { + return null + } + response.body.string() + } + }.getOrNull() + } + + private fun downloadLatest(outputFile: File): Boolean { + val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: return false + val request = Request.Builder() + .url("https://raw.githubusercontent.com/SnapEnhance/resources/main/sif/$abi.so") + .build() + runCatching { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return false + } + response.body.byteStream().use { input -> + outputFile.outputStream().use { output -> + input.copyTo(output) + } + } + return true + } + }.onFailure { + remoteSideContext.log.error("Failed to download latest sif", it) + } + return false + } + + fun init() { + val libraryFile = InternalFileHandleType.SIF.resolve(remoteSideContext.androidContext) + val currentVersion = remoteSideContext.sharedPreferences.getString("sif", null)?.trim() + if (currentVersion == null || currentVersion == "false") { + libraryFile.takeIf { it.exists() }?.delete() + remoteSideContext.log.info("sif can't be loaded due to user preference") + return + } + val latestVersion = getVersion()?.trim() ?: run { + remoteSideContext.log.warn("Failed to get latest sif version") + return + } + + if (currentVersion == latestVersion) { + remoteSideContext.log.info("sif is up to date ($currentVersion)") + return + } + + remoteSideContext.log.info("Updating sif from $currentVersion to $latestVersion") + if (downloadLatest(libraryFile)) { + remoteSideContext.sharedPreferences.edit().putString("sif", latestVersion).commit() + remoteSideContext.shortToast("SIF updated to $latestVersion!") + // force restart snapchat + remoteSideContext.bridgeService?.stopSelf() + } else { + remoteSideContext.log.warn("Failed to download latest sif") + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -78,6 +78,7 @@ class RemoteSideContext( val tracker = RemoteTracker(this) val accountStorage = RemoteAccountStorage(this) val locationManager = RemoteLocationManager(this) + val remoteSharedLibraryManager = RemoteSharedLibraryManager(this) //used to load bitmoji selfies and download previews val imageLoader by lazy { @@ -131,6 +132,9 @@ class RemoteSideContext( messageLogger.purgeTrackerLogs(it) } } + coroutineScope.launch { + remoteSharedLibraryManager.init() + } } }.onFailure { log.error("Failed to load RemoteSideContext", it) @@ -212,6 +216,10 @@ class RemoteSideContext( requirements = requirements or Requirements.MAPPINGS } + if (sharedPreferences.getString("sif", null) == null) { + requirements = requirements or Requirements.SIF + } + if (requirements == 0) return false val currentContext = activity ?: androidContext diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/EnumQuickActions.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/EnumQuickActions.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.action import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.ui.graphics.vector.ImageVector import me.rhunk.snapenhance.ui.manager.Routes @@ -21,4 +22,7 @@ enum class EnumQuickActions( LOGGER_HISTORY("logger_history", Icons.Default.History, { loggerHistory.navigateReset() }), + THEMING("theming", Icons.Default.Palette, { + theming.navigateReset() + }) } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt @@ -89,6 +89,19 @@ class AppDatabase( "longitude DOUBLE", "radius DOUBLE", ), + "themes" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "enabled BOOLEAN DEFAULT 0", + "name VARCHAR", + "description TEXT", + "version VARCHAR", + "author VARCHAR", + "updateUrl VARCHAR", + "content TEXT", + ), + "repositories" to listOf( + "url VARCHAR PRIMARY KEY", + ), )) } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getRepositories(): List<String> { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT url FROM repositories", null).use { cursor -> + val repos = mutableListOf<String>() + while (cursor.moveToNext()) { + repos.add(cursor.getStringOrNull("url") ?: continue) + } + repos + } + } +} + +fun AppDatabase.removeRepo(url: String) { + runBlocking(executor.asCoroutineDispatcher()) { + database.delete("repositories", "url = ?", arrayOf(url)) + } +} + +fun AppDatabase.addRepo(url: String) { + runBlocking(executor.asCoroutineDispatcher()) { + database.insert("repositories", null, ContentValues().apply { + put("url", url) + }) + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.data.DatabaseTheme +import me.rhunk.snapenhance.common.data.DatabaseThemeContent +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getThemeList(): List<DatabaseTheme> { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT * FROM themes ORDER BY id DESC", null).use { cursor -> + val themes = mutableListOf<DatabaseTheme>() + while (cursor.moveToNext()) { + themes.add( + DatabaseTheme( + id = cursor.getIntOrNull("id") ?: continue, + enabled = cursor.getIntOrNull("enabled") == 1, + name = cursor.getStringOrNull("name") ?: continue, + description = cursor.getStringOrNull("description"), + version = cursor.getStringOrNull("version"), + author = cursor.getStringOrNull("author"), + updateUrl = cursor.getStringOrNull("updateUrl") + ) + ) + } + themes + } + } +} + +fun AppDatabase.getThemeInfo(id: Int): DatabaseTheme? { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT * FROM themes WHERE id = ?", arrayOf(id.toString())).use { cursor -> + if (!cursor.moveToFirst()) return@use null + DatabaseTheme( + id = cursor.getIntOrNull("id") ?: return@use null, + enabled = cursor.getIntOrNull("enabled") == 1, + name = cursor.getStringOrNull("name") ?: return@use null, + description = cursor.getStringOrNull("description"), + version = cursor.getStringOrNull("version"), + author = cursor.getStringOrNull("author"), + updateUrl = cursor.getStringOrNull("updateUrl") + ) + } + } +} + +fun AppDatabase.getThemeIdByUpdateUrl(updateUrl: String): Int? { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT id FROM themes WHERE updateUrl = ?", arrayOf(updateUrl)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + cursor.getIntOrNull("id") + } + } +} + +fun AppDatabase.addOrUpdateTheme(theme: DatabaseTheme, themeId: Int? = null): Int { + return runBlocking(executor.asCoroutineDispatcher()) { + val contentValues = ContentValues().apply { + put("enabled", if (theme.enabled) 1 else 0) + put("name", theme.name) + put("description", theme.description) + put("version", theme.version) + put("author", theme.author) + put("updateUrl", theme.updateUrl) + } + if (themeId != null) { + database.update("themes", contentValues, "id = ?", arrayOf(themeId.toString())) + return@runBlocking themeId + } + database.insert("themes", null, contentValues).toInt() + } +} + +fun AppDatabase.setThemeState(id: Int, enabled: Boolean) { + runBlocking(executor.asCoroutineDispatcher()) { + database.update("themes", ContentValues().apply { + put("enabled", if (enabled) 1 else 0) + }, "id = ?", arrayOf(id.toString())) + } +} + +fun AppDatabase.deleteTheme(id: Int) { + runBlocking(executor.asCoroutineDispatcher()) { + database.delete("themes", "id = ?", arrayOf(id.toString())) + } +} + + +fun AppDatabase.getThemeContent(id: Int): DatabaseThemeContent? { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT content FROM themes WHERE id = ?", arrayOf(id.toString())).use { cursor -> + if (!cursor.moveToFirst()) return@use null + runCatching { + context.gson.fromJson(cursor.getStringOrNull("content"), DatabaseThemeContent::class.java) + }.getOrNull() + } + } +} + + +fun AppDatabase.getEnabledThemesContent(): List<DatabaseThemeContent> { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT content FROM themes WHERE enabled = 1", null).use { cursor -> + val themes = mutableListOf<DatabaseThemeContent>() + while (cursor.moveToNext()) { + runCatching { + themes.add(context.gson.fromJson(cursor.getStringOrNull("content"), DatabaseThemeContent::class.java)) + } + } + themes + } + } +} + + +fun AppDatabase.setThemeContent(id: Int, content: DatabaseThemeContent) { + runBlocking(executor.asCoroutineDispatcher()) { + database.update("themes", ContentValues().apply { + put("content", context.gson.toJson(content)) + }, "id = ?", arrayOf(id.toString())) + } +} 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 @@ -24,6 +24,9 @@ import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection +import me.rhunk.snapenhance.ui.manager.pages.theming.EditThemeSection +import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection +import me.rhunk.snapenhance.ui.manager.pages.theming.ThemingRoot import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot @@ -58,6 +61,10 @@ class Routes( val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule()) val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home) + val theming = route(RouteInfo("theming"), ThemingRoot()).parent(home) + val editTheme = route(RouteInfo("edit_theme/?theme_id={theme_id}"), EditThemeSection()) + val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection()) + val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection()) 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/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt @@ -38,6 +38,7 @@ import me.rhunk.snapenhance.common.data.download.DownloadRequest import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.createNewFilePath import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.transparentTextFieldColors import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.protobuf.ProtoReader @@ -373,14 +374,7 @@ class LoggerHistoryRoot : Routes.Route() { .padding(end = 10.dp) .height(70.dp), singleLine = true, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedContainerColor = MaterialTheme.colorScheme.surface, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ) + colors = transparentTextFieldColors() ) LaunchedEffect(Unit) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt @@ -0,0 +1,187 @@ +package me.rhunk.snapenhance.ui.manager.pages + +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.Public +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.RepositoryIndex +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard +import me.rhunk.snapenhance.storage.addRepo +import me.rhunk.snapenhance.storage.getRepositories +import me.rhunk.snapenhance.storage.removeRepo +import me.rhunk.snapenhance.ui.manager.Routes +import okhttp3.OkHttpClient + +class ManageReposSection: Routes.Route() { + private val updateDispatcher = AsyncUpdateDispatcher() + private val okHttpClient by lazy { OkHttpClient() } + + override val floatingActionButton: @Composable () -> Unit = { + var showAddDialog by remember { mutableStateOf(false) } + ExtendedFloatingActionButton(onClick = { + showAddDialog = true + }) { + Text("Add Repository") + } + + if (showAddDialog) { + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + + suspend fun addRepo(url: String) { + var modifiedUrl = url; + + if (url.startsWith("https://github.com/")) { + val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/") + val repoName = splitUrl[0] + "/" + splitUrl[1] + // fetch default branch + okHttpClient.newCall( + okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build() + ).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch default branch: ${response.code}") + } + val json = response.body.string() + val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String + context.log.info("Default branch for $repoName is $defaultBranch") + modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/" + } + } + + val indexUri = modifiedUrl.toUri().buildUpon().appendPath("index.json").build() + okHttpClient.newCall( + okhttp3.Request.Builder().url(indexUri.toString()).build() + ).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch index from $indexUri: ${response.code}") + } + runCatching { + val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also { + context.log.info("repository index: $it") + } + + context.database.addRepo(modifiedUrl) + context.shortToast("Repository added successfully! $repoIndex") + showAddDialog = false + updateDispatcher.dispatch() + }.onFailure { + throw Exception("Failed to parse index from $indexUri") + } + } + } + + var url by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + + AlertDialog(onDismissRequest = { + showAddDialog = false + }, title = { + Text("Add Repository URL") + }, text = { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + }, + value = url, + onValueChange = { + url = it + }, label = { + Text("Repository URL") + } + ) + LaunchedEffect(Unit) { + context.androidContext.getUrlFromClipboard()?.let { + url = it + } + } + }, confirmButton = { + Button( + enabled = !loading, + onClick = { + loading = true; + coroutineScope.launch { + runCatching { + addRepo(url) + }.onFailure { + context.log.error("Failed to add repository", it) + context.shortToast("Failed to add repository: ${it.message}") + } + loading = false + } + } + ) { + if (loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text("Add") + } + } + }) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) { + context.database.getRepositories() + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + ) { + item { + if (repositories.isEmpty()) { + Text("No repositories added", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center) + } + } + items(repositories) { url -> + ElevatedCard(onClick = {}) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon(Icons.Default.Public, contentDescription = null) + Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + Button( + onClick = { + context.database.removeRepo(url) + coroutineScope.launch { + updateDispatcher.dispatch() + } + } + ) { + Text("Remove") + } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt @@ -40,6 +40,7 @@ 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.common.ui.transparentTextFieldColors import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.* @@ -177,11 +178,15 @@ class FeaturesRootSection : Routes.Route() { .fillMaxWidth(), ) { LazyColumn( - modifier = Modifier.fillMaxWidth().padding(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), ) { item { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( @@ -200,10 +205,13 @@ class FeaturesRootSection : Routes.Route() { } 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), + 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)) @@ -321,23 +329,13 @@ class FeaturesRootSection : Routes.Route() { DataProcessors.Type.INT_COLOR -> { dialogComposable = { - alertDialogs.ColorPickerDialog(property) { + alertDialogs.ColorPickerPropertyDialog(property) { showDialog = false } } registerDialogOnClickCallback().let { { it.invoke(true) } }.also { - val selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) } - AlphaTile( - modifier = Modifier - .size(30.dp) - .border(2.dp, Color.White, shape = RoundedCornerShape(15.dp)) - .clip(RoundedCornerShape(15.dp)), - selectedColor = selectedColor ?: Color.Transparent, - tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent, - tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent, - tileSize = 8.dp, - ) + CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }) } } @@ -489,14 +487,7 @@ class FeaturesRootSection : Routes.Route() { .padding(end = 10.dp) .height(70.dp), singleLine = true, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedContainerColor = MaterialTheme.colorScheme.surface, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ) + colors = transparentTextFieldColors() ) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -154,6 +154,9 @@ class HomeSettings : Routes.Route() { RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) } + RowAction(key = "security_features") { + context.checkForRequirements(Requirements.SIF) + } RowTitle(title = translation["message_logger_title"]) ShiftedRow { Column( @@ -284,7 +287,7 @@ class HomeSettings : Routes.Route() { ) { PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading") PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper") - PreferenceToggle(context.sharedPreferences, key = "force_native_load", text = "Force Native Load") + PreferenceToggle(context.sharedPreferences, key = "disable_sif", text = "Disable Security Features") } } Spacer(modifier = Modifier.height(50.dp)) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt @@ -22,11 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager @@ -34,6 +30,7 @@ import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard import me.rhunk.snapenhance.storage.isScriptEnabled import me.rhunk.snapenhance.storage.setScriptEnabled import me.rhunk.snapenhance.ui.manager.Routes @@ -101,6 +98,11 @@ class ScriptingRootSection : Routes.Route() { focusRequester.requestFocus() } ) + LaunchedEffect(Unit) { + context.androidContext.getUrlFromClipboard()?.let { + url = it + } + } Spacer(modifier = Modifier.height(8.dp)) Button( enabled = url.isNotBlank(), diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/EditThemeSection.kt @@ -0,0 +1,393 @@ +package me.rhunk.snapenhance.ui.manager.pages.theming + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.* +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.transparentTextFieldColors +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.CircularAlphaTile +import me.rhunk.snapenhance.ui.util.Dialog + +class EditThemeSection: Routes.Route() { + private var saveCallback by mutableStateOf<(() -> Unit)?>(null) + private var addEntryCallback by mutableStateOf<(key: String, initialColor: Int) -> Unit>({ _, _ -> }) + private var deleteCallback by mutableStateOf<(() -> Unit)?>(null) + private var themeColors = mutableStateListOf<ThemeColorEntry>() + + private val alertDialogs by lazy { + AlertDialogs(context.translation) + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var deleteConfirmationDialog by remember { mutableStateOf(false) } + + if (deleteConfirmationDialog) { + Dialog(onDismissRequest = { + deleteConfirmationDialog = false + }) { + alertDialogs.ConfirmDialog( + title = "Delete Theme", + message = "Are you sure you want to delete this theme?", + onConfirm = { + deleteCallback?.invoke() + deleteConfirmationDialog = false + }, + onDismiss = { + deleteConfirmationDialog = false + } + ) + } + } + + deleteCallback?.let { + IconButton(onClick = { + deleteConfirmationDialog = true + }) { + Icon(Icons.Default.Delete, contentDescription = null) + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + override val floatingActionButton: @Composable () -> Unit = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + var addAttributeDialog by remember { mutableStateOf(false) } + val attributesTranslation = remember { context.translation.getCategory("theming_attributes") } + + if (addAttributeDialog) { + AlertDialog( + title = { Text("Select an attribute to add") }, + onDismissRequest = { + addAttributeDialog = false + }, + confirmButton = {}, + text = { + var filter by remember { mutableStateOf("") } + val attributes = rememberAsyncMutableStateList(defaultValue = listOf(), keys = arrayOf(filter)) { + AvailableThemingAttributes[ThemingAttributeType.COLOR]?.filter { key -> + themeColors.none { it.key == key } && (key.contains(filter, ignoreCase = true) || attributesTranslation.getOrNull(key)?.contains(filter, ignoreCase = true) == true) + } ?: emptyList() + } + + LazyColumn( + modifier = Modifier + .fillMaxHeight(0.7f) + .fillMaxWidth(), + ) { + stickyHeader { + TextField( + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), + value = filter, + onValueChange = { filter = it }, + label = { Text("Search") }, + colors = transparentTextFieldColors().copy( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright + ) + ) + } + item { + if (attributes.isEmpty()) { + Text("No attributes") + } + } + items(attributes) { attribute -> + Card( + modifier = Modifier.padding(5.dp).fillMaxWidth(), + onClick = { + addEntryCallback(attribute, Color.White.toArgb()) + addAttributeDialog = false + } + ) { + val attributeTranslation = remember(attribute) { + attributesTranslation.getOrNull(attribute) + } + + Column( + modifier = Modifier.padding(8.dp) + ) { + Text(attributeTranslation ?: attribute, lineHeight = 15.sp) + attributeTranslation?.let { + Text(attribute, fontWeight = FontWeight.Light, fontSize = 10.sp, lineHeight = 15.sp) + } + } + } + } + } + } + ) + } + + FloatingActionButton(onClick = { + addAttributeDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = null) + } + + saveCallback?.let { + FloatingActionButton(onClick = { + it() + }) { + Icon(Icons.Default.Save, contentDescription = null) + } + } + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val currentThemeId = remember { it.arguments?.getString("theme_id")?.toIntOrNull() } + + LaunchedEffect(Unit) { + themeColors.clear() + } + + var themeName by remember { mutableStateOf("") } + var themeDescription by remember { mutableStateOf("") } + var themeVersion by remember { mutableStateOf("1.0.0") } + var themeAuthor by remember { mutableStateOf("") } + var themeUpdateUrl by remember { mutableStateOf("") } + + val themeInfo by rememberAsyncMutableState(defaultValue = null) { + currentThemeId?.let { themeId -> + context.database.getThemeInfo(themeId)?.also { theme -> + themeName = theme.name + themeDescription = theme.description ?: "" + theme.version?.let { themeVersion = it } + themeAuthor = theme.author ?: "" + themeUpdateUrl = theme.updateUrl ?: "" + } + } + } + + val lazyListState = rememberLazyListState() + + rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) { + currentThemeId?.let { + context.database.getThemeContent(it)?.also { content -> + themeColors.clear() + themeColors.addAll(content.colors) + withContext(Dispatchers.Main) { + lazyListState.scrollToItem(themeColors.size) + } + } + } ?: DatabaseThemeContent() + } + + if (themeName.isNotBlank()) { + saveCallback = { + coroutineScope.launch(Dispatchers.IO) { + val theme = DatabaseTheme( + id = currentThemeId ?: -1, + enabled = themeInfo?.enabled ?: false, + name = themeName, + description = themeDescription, + version = themeVersion, + author = themeAuthor, + updateUrl = themeUpdateUrl + ) + val themeId = context.database.addOrUpdateTheme(theme, currentThemeId) + context.database.setThemeContent(themeId, DatabaseThemeContent( + colors = themeColors + )) + withContext(Dispatchers.Main) { + routes.theming.navigateReload() + } + } + } + } else { + saveCallback = null + } + + LaunchedEffect(Unit) { + deleteCallback = null + if (currentThemeId != null) { + deleteCallback = { + coroutineScope.launch(Dispatchers.IO) { + context.database.deleteTheme(currentThemeId) + withContext(Dispatchers.Main) { + routes.theming.navigateReload() + } + } + } + } + addEntryCallback = { key, initialColor -> + coroutineScope.launch(Dispatchers.Main) { + themeColors.add(ThemeColorEntry(key, initialColor)) + delay(100) + lazyListState.scrollToItem(themeColors.size) + } + } + } + + var moreOptionsExpanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + val focusRequester = remember { FocusRequester() } + + TextField( + modifier = Modifier.weight(1f).focusRequester(focusRequester), + value = themeName, + onValueChange = { themeName = it }, + label = { Text("Theme Name") }, + colors = transparentTextFieldColors(), + singleLine = true, + ) + LaunchedEffect(Unit) { + if (currentThemeId == null) { + delay(200) + focusRequester.requestFocus() + } + } + IconButton( + modifier = Modifier.padding(4.dp), + onClick = { + moreOptionsExpanded = !moreOptionsExpanded + } + ) { + Icon(if (moreOptionsExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = null) + } + } + + if (moreOptionsExpanded) { + TextField( + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + value = themeDescription, + onValueChange = { themeDescription = it }, + label = { Text("Description") }, + colors = transparentTextFieldColors() + ) + TextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = themeVersion, + onValueChange = { themeVersion = it }, + label = { Text("Version") }, + colors = transparentTextFieldColors() + ) + TextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = themeAuthor, + onValueChange = { themeAuthor = it }, + label = { Text("Author") }, + colors = transparentTextFieldColors() + ) + TextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = themeUpdateUrl, + onValueChange = { themeUpdateUrl = it }, + label = { Text("Update URL") }, + colors = transparentTextFieldColors() + ) + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + state = lazyListState, + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + reverseLayout = true, + ) { + item { + Spacer(modifier = Modifier.height(150.dp)) + } + items(themeColors) { colorEntry -> + var showEditColorDialog by remember { mutableStateOf(false) } + var currentColor by remember { mutableIntStateOf(colorEntry.value) } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth(), + onClick = { + showEditColorDialog = true + } + ) { + Row( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Colorize, contentDescription = null, modifier = Modifier.padding(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + val translation = remember(colorEntry.key) { context.translation.getOrNull("theming_attributes.${colorEntry.key}") } + Text(text = translation ?: colorEntry.key, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 15.sp) + translation?.let { + Text(text = colorEntry.key, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 15.sp) + } + } + CircularAlphaTile(selectedColor = Color(currentColor)) + } + } + + if (showEditColorDialog) { + Dialog(onDismissRequest = { showEditColorDialog = false }) { + alertDialogs.ColorPickerDialog( + initialColor = Color(currentColor), + setProperty = { + if (it == null) { + themeColors.remove(colorEntry) + return@ColorPickerDialog + } + currentColor = it.toArgb() + colorEntry.value = currentColor + }, + dismiss = { + showEditColorDialog = false + } + ) + } + } + } + item { + if (themeColors.isEmpty()) { + Text("No colors added yet", modifier = Modifier + .fillMaxWidth() + .padding(8.dp), fontWeight = FontWeight.Light, textAlign = TextAlign.Center) + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemeCatalog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemeCatalog.kt @@ -0,0 +1,272 @@ +package me.rhunk.snapenhance.ui.manager.pages.theming + +import android.net.Uri +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.Palette +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.data.RepositoryIndex +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.storage.getThemeList +import me.rhunk.snapenhance.storage.getRepositories +import me.rhunk.snapenhance.storage.getThemeIdByUpdateUrl +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState +import okhttp3.Request + + +private val cachedRepoIndexes = mutableStateMapOf<String, RepositoryIndex>() +private val cacheReloadDispatcher = AsyncUpdateDispatcher() + +@Composable +fun ThemeCatalog(root: ThemingRoot) { + val context = remember { root.context } + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + + fun fetchRepoIndexes(): Map<String, RepositoryIndex>? { + val indexes = mutableMapOf<String, RepositoryIndex>() + + context.database.getRepositories().forEach { rootUri -> + val indexUri = rootUri.toUri().buildUpon().appendPath("index.json").build() + + runCatching { + root.okHttpClient.newCall( + Request.Builder().url(indexUri.toString()).build() + ).execute().use { response -> + if (!response.isSuccessful) { + context.log.error("Failed to fetch theme index from $indexUri: ${response.code}") + context.shortToast("Failed to fetch index of $indexUri") + return@forEach + } + + runCatching { + indexes[rootUri] = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java) + }.onFailure { + context.log.error("Failed to parse theme index from $indexUri", it) + context.shortToast("Failed to parse index of $indexUri") + } + } + }.onFailure { + context.log.error("Failed to fetch theme index from $indexUri", it) + context.shortToast("Failed to fetch index of $indexUri") + } + } + + return indexes + } + + suspend fun installTheme(themeUri: Uri) { + root.okHttpClient.newCall( + Request.Builder().url(themeUri.toString()).build() + ).execute().use { response -> + if (!response.isSuccessful) { + context.log.error("Failed to fetch theme from $themeUri: ${response.code}") + context.shortToast("Failed to fetch theme from $themeUri") + return + } + + val themeContent = response.body.bytes().toString(Charsets.UTF_8) + root.importTheme(themeContent, themeUri.toString()) + } + } + + var isRefreshing by remember { mutableStateOf(false) } + + suspend fun refreshCachedIndexes() { + isRefreshing = true + coroutineScope { + launch(Dispatchers.IO) { + fetchRepoIndexes()?.let { + context.log.verbose("Fetched ${it.size} theme indexes") + it.forEach { (t, u) -> + context.log.verbose("Fetched theme index from $t with ${u.themes.size} themes") + } + synchronized(cachedRepoIndexes) { + cachedRepoIndexes.clear() + cachedRepoIndexes += it + } + cacheReloadDispatcher.dispatch() + delay(600) + isRefreshing = false + } + } + } + } + + val installedThemes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = root.localReloadDispatcher, keys = arrayOf(cachedRepoIndexes)) { + context.database.getThemeList() + } + + val remoteThemes by rememberAsyncMutableState(defaultValue = listOf(), updateDispatcher = cacheReloadDispatcher, keys = arrayOf(root.searchFilter.value)) { + cachedRepoIndexes.entries.flatMap { + it.value.themes.map { theme -> it.key to theme } + }.let { + val filter = root.searchFilter.value + if (filter.isNotBlank()) { + it.filter { (_, theme) -> + theme.name.contains(filter, ignoreCase = true) || theme.description?.contains(filter, ignoreCase = true) == true + } + } else it + } + } + + LaunchedEffect(Unit) { + if (cachedRepoIndexes.isNotEmpty()) return@LaunchedEffect + isRefreshing = true + coroutineScope.launch { + refreshCachedIndexes() + } + } + + val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { + coroutineScope.launch { + refreshCachedIndexes() + } + }) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState), + contentPadding = PaddingValues(8.dp) + ) { + item { + if (remoteThemes.isEmpty()) { + Text( + text = "No themes available", + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 15.sp, + fontWeight = FontWeight.Light + ) + } + } + items(remoteThemes, key = { it.first + it.second.hashCode() }) { (_, themeManifest) -> + val themeUri = remember { + cachedRepoIndexes.entries.find { it.value.themes.contains(themeManifest) }?.key?.toUri()?.buildUpon()?.appendPath(themeManifest.filepath)?.build() + } + + val hasUpdate by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) { + installedThemes.takeIf { themeUri != null }?.find { it.updateUrl == themeUri.toString() }?.let { installedTheme -> + installedTheme.version != themeManifest.version + } ?: false + } + + var isInstalling by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) { + false + } + + var isInstalled by rememberAsyncMutableState(defaultValue = true, keys = arrayOf(themeManifest)) { + context.database.getThemeIdByUpdateUrl(themeUri.toString()) != null + } + + ElevatedCard(onClick = { + //TODO: Show theme details + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = themeManifest.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + themeManifest.author?.let { + Text( + text = "by $it", + maxLines = 1, + fontSize = 10.sp, + fontWeight = FontWeight.Light, + overflow = TextOverflow.Ellipsis, + ) + } + } + themeManifest.description?.let { + Text( + text = it, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (hasUpdate) { + Text( + text = "Version ${themeManifest.version} available", + fontWeight = FontWeight.Bold + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (isInstalling) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + Button( + enabled = !isInstalled || hasUpdate, + onClick = { + isInstalling = true + context.coroutineScope.launch { + runCatching { + installTheme(themeUri ?: throw IllegalStateException("Failed to get theme URI")) + isInstalled = true + }.onFailure { + context.log.error("Failed to install theme ${themeManifest.name}", it) + context.shortToast("Failed to install theme ${themeManifest.name}. ${it.message}") + } + isInstalling = false + } + } + ) { + if (hasUpdate) { + Text("Update") + } else { + Text(if (isInstalled) "Installed" else "Install") + } + } + } + } + } + } + } + } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/theming/ThemingRoot.kt @@ -0,0 +1,463 @@ +package me.rhunk.snapenhance.ui.manager.pages.theming + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.DatabaseTheme +import me.rhunk.snapenhance.common.data.DatabaseThemeContent +import me.rhunk.snapenhance.common.data.ExportedTheme +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.transparentTextFieldColors +import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.openFile +import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset +import me.rhunk.snapenhance.ui.util.saveFile +import okhttp3.OkHttpClient + +class ThemingRoot: Routes.Route() { + val localReloadDispatcher = AsyncUpdateDispatcher() + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + private var currentPage by mutableIntStateOf(0) + val okHttpClient by lazy { OkHttpClient() } + val searchFilter = mutableStateOf("") + + + private fun exportTheme(theme: DatabaseTheme) { + context.coroutineScope.launch { + val exportedTheme = theme.toExportedTheme(context.database.getThemeContent(theme.id) ?: DatabaseThemeContent()) + + activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri -> + runCatching { + context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> + outputStream.write(context.gson.toJson(exportedTheme).toByteArray()) + outputStream.flush() + } + context.shortToast("Theme exported successfully") + }.onFailure { + context.log.error("Failed to save theme", it) + context.longToast("Failed to export theme! Check logs for more details") + } + } + } + } + + private fun duplicateTheme(theme: DatabaseTheme) { + context.coroutineScope.launch { + val themeId = context.database.addOrUpdateTheme(theme.copy( + updateUrl = null + )) + context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent()) + context.shortToast("Theme duplicated successfully") + withContext(Dispatchers.Main) { + localReloadDispatcher.dispatch() + } + } + } + + suspend fun importTheme(content: String, updateUrl: String? = null) { + val theme = context.gson.fromJson(content, ExportedTheme::class.java) + val existingTheme = updateUrl?.let { + context.database.getThemeIdByUpdateUrl(it) + }?.let { + context.database.getThemeInfo(it) + } + val databaseTheme = theme.toDatabaseTheme( + updateUrl = updateUrl, + enabled = existingTheme?.enabled ?: false + ) + + val themeId = context.database.addOrUpdateTheme( + themeId = existingTheme?.id, + theme = databaseTheme + ) + + context.database.setThemeContent(themeId, theme.content) + context.shortToast("Theme imported successfully") + withContext(Dispatchers.Main) { + localReloadDispatcher.dispatch() + } + } + + private fun importTheme() { + activityLauncherHelper.openFile { uri -> + context.coroutineScope.launch { + runCatching { + val themeJson = context.androidContext.contentResolver.openInputStream(uri.toUri())?.bufferedReader().use { + it?.readText() + } ?: throw Exception("Failed to read file") + + importTheme(themeJson) + }.onFailure { + context.log.error("Failed to import theme", it) + context.longToast("Failed to import theme! Check logs for more details") + } + } + } + } + + private suspend fun importFromURL(url: String) { + val result = okHttpClient.newCall( + okhttp3.Request.Builder() + .url(url) + .build() + ).execute() + + if (!result.isSuccessful) { + throw Exception("Failed to fetch theme from URL ${result.message}") + } + + importTheme(result.body.string(), url) + } + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var showSearchBar by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (showSearchBar) { + OutlinedTextField( + value = searchFilter.value, + onValueChange = { searchFilter.value = it }, + placeholder = { Text("Search") }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + }, + colors = transparentTextFieldColors() + ) + DisposableEffect(Unit) { + onDispose { + searchFilter.value = "" + } + } + } + IconButton(onClick = { + showSearchBar = !showSearchBar + }) { + Icon(if (showSearchBar) Icons.Default.Close else Icons.Default.Search, contentDescription = null) + } + } + } + + override val floatingActionButton: @Composable () -> Unit = { + var showImportFromUrlDialog by remember { mutableStateOf(false) } + + if (showImportFromUrlDialog) { + var url by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { showImportFromUrlDialog = false }, + title = { Text("Import theme from URL") }, + text = { + val focusRequester = remember { FocusRequester() } + TextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL") }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + } + ) + LaunchedEffect(Unit) { + context.androidContext.getUrlFromClipboard()?.let { + url = it + } + } + }, + confirmButton = { + Button( + enabled = url.isNotBlank() && !loading, + onClick = { + loading = true + context.coroutineScope.launch { + runCatching { + importFromURL(url) + withContext(Dispatchers.Main) { + showImportFromUrlDialog = false + } + }.onFailure { + context.log.error("Failed to import theme", it) + context.longToast("Failed to import theme! ${it.message}") + } + withContext(Dispatchers.Main) { + loading = false + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Import") + } + } + ) + } + Column( + horizontalAlignment = Alignment.End + ) { + when (currentPage) { + 0 -> { + ExtendedFloatingActionButton( + onClick = { + routes.editTheme.navigate() + }, + icon = { + Icon(Icons.Default.Add, contentDescription = null) + }, + text = { + Text("New theme") + } + ) + Spacer(modifier = Modifier.height(8.dp)) + ExtendedFloatingActionButton( + onClick = { + importTheme() + }, + icon = { + Icon(Icons.Default.Upload, contentDescription = null) + }, + text = { + Text("Import from file") + } + ) + Spacer(modifier = Modifier.height(8.dp)) + ExtendedFloatingActionButton( + onClick = { showImportFromUrlDialog = true }, + icon = { + Icon(Icons.Default.Link, contentDescription = null) + }, + text = { + Text("Import from URL") + } + ) + } + 1 -> { + ExtendedFloatingActionButton( + onClick = { + routes.manageRepos.navigate() + }, + icon = { + Icon(Icons.Default.Public, contentDescription = null) + }, + text = { + Text("Manage repositories") + } + ) + } + } + } + } + + @Composable + private fun InstalledThemes() { + val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = localReloadDispatcher, keys = arrayOf(searchFilter.value)) { + context.database.getThemeList().let { + val filter = searchFilter.value + if (filter.isNotBlank()) { + it.filter { theme -> + theme.name.contains(filter, ignoreCase = true) || + theme.author?.contains(filter, ignoreCase = true) == true || + theme.description?.contains(filter, ignoreCase = true) == true + } + } else it + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(2.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + item { + if (themes.isEmpty()) { + Text( + text = translation["no_themes_hint"], + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 15.sp, + fontWeight = FontWeight.Light + ) + } + } + items(themes, key = { it.id }) { theme -> + var showSettings by remember(theme) { mutableStateOf(false) } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + routes.editTheme.navigate { + this["theme_id"] = theme.id.toString() + } + } + .padding(8.dp) + ) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(5.dp)) + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp), + ) { + Text(text = theme.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 20.sp) + theme.author?.takeIf { it.isNotBlank() }?.let { + Text(text = "by $it", lineHeight = 15.sp, fontWeight = FontWeight.Light, fontSize = 12.sp) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + var state by remember { mutableStateOf(theme.enabled) } + + IconButton(onClick = { + showSettings = true + }) { + Icon(Icons.Default.Settings, contentDescription = null) + } + + Switch(checked = state, onCheckedChange = { + state = it + context.database.setThemeState(theme.id, it) + }) + } + } + } + + if (showSettings) { + val actionsRow = remember { + mapOf( + ("Duplicate" to Icons.Default.ContentCopy) to { duplicateTheme(theme) }, + ("Export" to Icons.Default.Download) to { exportTheme(theme) } + ) + } + AlertDialog( + onDismissRequest = { showSettings = false }, + title = { Text("Theme settings") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + actionsRow.forEach { entry -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showSettings = false + entry.value() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(entry.key.second, contentDescription = null, modifier = Modifier.padding(16.dp)) + Spacer(modifier = Modifier.width(5.dp)) + Text(entry.key.first) + } + } + } + }, + confirmButton = {} + ) + } + } + item { + Spacer(modifier = Modifier.height(100.dp)) + } + } + } + + + @OptIn(ExperimentalFoundationApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val titles = remember { listOf("Installed Themes", "Catalog") } + val pagerState = rememberPagerState { titles.size } + currentPage = pagerState.currentPage + + Column { + TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.pagerTabIndicatorOffset( + pagerState = pagerState, + tabPositions = tabPositions + ) + ) + }) { + titles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + text = title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + HorizontalPager( + modifier = Modifier.weight(1f), + state = pagerState + ) { page -> + when (page) { + 0 -> InstalledThemes() + 1 -> ThemeCatalog(this@ThemingRoot) + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt @@ -74,9 +74,12 @@ fun LogsTab( suspend fun loadNewLogs() { withContext(Dispatchers.IO) { - logs.addAll(getPaginatedLogs(pageIndex).apply { - pageIndex += 1 - }) + getPaginatedLogs(pageIndex).let { + withContext(Dispatchers.Main) { + logs.addAll(it) + pageIndex += 1 + } + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt @@ -1,21 +1,11 @@ package me.rhunk.snapenhance.ui.setup object Requirements { - const val FIRST_RUN = 0b00001 - const val LANGUAGE = 0b00010 - const val MAPPINGS = 0b00100 - const val SAVE_FOLDER = 0b01000 - const val GRANT_PERMISSIONS = 0b10000 - - fun getName(requirement: Int): String { - return when (requirement) { - FIRST_RUN -> "FIRST_RUN" - LANGUAGE -> "LANGUAGE" - MAPPINGS -> "MAPPINGS" - SAVE_FOLDER -> "SAVE_FOLDER" - GRANT_PERMISSIONS -> "GRANT_PERMISSIONS" - else -> "UNKNOWN" - } - } + const val FIRST_RUN = 0b000001 + const val LANGUAGE = 0b000010 + const val MAPPINGS = 0b000100 + const val SAVE_FOLDER = 0b001000 + const val GRANT_PERMISSIONS = 0b010000 + const val SIF = 0b100000 } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt @@ -29,10 +29,7 @@ import androidx.navigation.compose.rememberNavController import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.common.ui.AppMaterialTheme import me.rhunk.snapenhance.ui.setup.screens.SetupScreen -import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen -import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen -import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen -import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.* class SetupActivity : ComponentActivity() { @@ -69,6 +66,9 @@ class SetupActivity : ComponentActivity() { if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) { add(MappingsScreen().apply { route = "mappings" }) } + if (isFirstRun || hasRequirement(Requirements.SIF)) { + add(SecurityScreen().apply { route = "security" }) + } } // If there are no required screens, we can just finish the activity diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SecurityScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SecurityScreen.kt @@ -0,0 +1,86 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen + +class SecurityScreen : SetupScreen() { + @Composable + override fun Content() { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + modifier = Modifier.padding(16.dp).size(30.dp), + ) + + DialogText( + "Since Snapchat has implemented additional security measures against third-party applications such as SnapEnhance, we offer a non-opensource solution that reduces the risk of banning and prevents Snapchat from detecting SnapEnhance. " + + "\nPlease note that this solution does not provide a ban bypass or spoofer for anything, and does not take any personal data or communicate with the network." + + "\nWe also encourage you to use official signed builds to avoid compromising the security of your account." + + "\nIf you're having trouble using the solution, or are experiencing crashes, join the Telegram Group for help: https://t.me/snapenhance_chat" + ) + + var denyDialog by remember { mutableStateOf(false) } + + if (denyDialog) { + AlertDialog( + onDismissRequest = { + denyDialog = false + }, + text = { + Text("Are you sure you don't want to use this solution? You can always change this later in the settings in the SnapEnhance app.") + }, + dismissButton = { + Button(onClick = { + denyDialog = false + }) { + Text("Go back") + } + }, + confirmButton = { + Button(onClick = { + context.sharedPreferences.edit().putString("sif", "false").apply() + goNext() + }) { + Text("Yes, I'm sure") + } + } + ) + } + + Column ( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + onClick = { + context.coroutineScope.launch { + context.sharedPreferences.edit().putString("sif", "").commit() + context.remoteSharedLibraryManager.init() + } + goNext() + } + ) { + Text("Accept and continue", fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onClick = { + denyDialog = true + } + ) { + Text("I don't want to use this solution") + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -360,12 +360,11 @@ class AlertDialogs( @Composable fun ColorPickerDialog( - property: PropertyPair<*>, - dismiss: () -> Unit = {}, + initialColor: Color?, + setProperty: (Color?) -> Unit, + dismiss: () -> Unit ) { - var currentColor by remember { - mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) }) - } + var currentColor by remember { mutableStateOf(initialColor) } DefaultDialogCard { val controller = remember { ColorPickerController().apply { @@ -389,7 +388,7 @@ class AlertDialogs( runCatching { currentColor = Color(android.graphics.Color.parseColor("#$value")).also { controller.selectByColor(it, true) - property.value.setAny(it.toArgb()) + setProperty(it) } }.onFailure { currentColor = null @@ -417,7 +416,7 @@ class AlertDialogs( if (!it.fromUser) return@HsvColorPicker currentColor = it.color colorHexValue = Integer.toHexString(it.color.toArgb()) - property.value.setAny(it.color.toArgb()) + setProperty(it.color) } ) AlphaSlider( @@ -450,7 +449,7 @@ class AlertDialogs( controller = controller ) IconButton(onClick = { - property.value.setAny(null) + setProperty(null) dismiss() }) { Icon( @@ -464,6 +463,25 @@ class AlertDialogs( } @Composable + fun ColorPickerPropertyDialog( + property: PropertyPair<*>, + dismiss: () -> Unit = {}, + ) { + var currentColor by remember { + mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) }) + } + + ColorPickerDialog( + initialColor = currentColor, + setProperty = { + currentColor = it + property.value.setAny(it?.toArgb()) + }, + dismiss = dismiss + ) + } + + @Composable fun ChooseLocationDialog( property: PropertyPair<*>, marker: MutableState<Marker?> = remember { mutableStateOf(null) }, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ColorPicker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ColorPicker.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.skydoves.colorpicker.compose.AlphaTile + +@Composable +fun CircularAlphaTile( + selectedColor: Color?, +) { + AlphaTile( + modifier = Modifier + .size(30.dp) + .border(2.dp, Color.White, shape = RoundedCornerShape(15.dp)) + .clip(RoundedCornerShape(15.dp)), + selectedColor = selectedColor ?: Color.Transparent, + tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent, + tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent, + tileSize = 8.dp, + ) +}+ \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -36,6 +36,9 @@ "friend_tracker": "Friend Tracker", "edit_rule": "Edit Rule", "file_imports": "File Imports", + "theming": "Theming", + "edit_theme": "Edit Theme", + "manage_repos": "Manage Repositories", "social": "Social", "manage_scope": "Manage Scope", "messaging_preview": "Preview", @@ -168,6 +171,9 @@ "search_bar": "Search", "no_friends_map": "No friends on the map", "no_friends_found": "No friends found" + }, + "theming": { + "no_themes_hint": "No themes found" } }, "dialogs": { @@ -293,6 +299,10 @@ "name": "Change Language", "description": "Change the language of SnapEnhance" }, + "security_features": { + "name": "Security Features", + "description": "Access security features" + }, "file_imports": { "name": "File Imports", "description": "Import files for use in Snapchat" @@ -304,6 +314,10 @@ "logger_history": { "name": "Logger History", "description": "View the history of logged messages" + }, + "theming": { + "name": "Theming", + "description": "Customize the look and feel of Snapchat" } }, @@ -415,65 +429,9 @@ "name": "Enable App Appearance Settings", "description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions" }, - "customize_ui": { - "name": "Colors", - "description": "Customize Snapchats Colors", - "properties": { - "theme_picker": { - "name": "Theme Picker", - "description": "Preset Snapchat Themes" - }, - "colors": { - "name": "Custom Colors", - "description": "Customize Individual colors\nNote: Select Custom Colors on Theme Picker to use", - "properties": { - "text_color": { - "name": "Main Text Color", - "description": "Changes Snapchats main text color" - }, - "chat_chat_text_color": { - "name": "Main Friend Feed Text Color", - "description": "Changes the text color of ( New Chat / New Snap And Chats / Typing / Calling / Missed call / Speaking / New Voice Note ) on the friend feed" - }, - "pending_sending_text_color": { - "name": "Secondary Friend Feed Text Color", - "description": "Changes the text color of ( Delivered / Received / Sending / Opened / Tap To Chat / Hold To Replay / Replayed / Saved In Chat / Called ) on the friend feed" - }, - "snap_with_sound_text_color": { - "name": "Snaps With Sound Text Color", - "description": "Changes the text color of ( New Snap ) on the friend feed\nNote: Video Snaps Only" - }, - "snap_without_sound_text_color": { - "name": "Snaps Without Sound Text Color", - "description": "Changes the text color of ( New Snap ) on the friend feed\nNote: Video Snaps Only" - }, - "background_color": { - "name": "Background Color", - "description": "Changes Snapchats background color" - }, - "background_color_surface": { - "name": "Background Surface Color", - "description": "Changes Snapchats background surface color" - }, - "friend_feed_conversations_line_color": { - "name": "Conversations Line Color", - "description": "Changes the line divider color that splits Conversations on the friend feed " - }, - "action_menu_background_color": { - "name": "Action Menu Background Color", - "description": "Changes Snapchats chat action menu background color" - }, - "action_menu_round_background_color": { - "name": "Action Menu Round Background Color", - "description": "Changes Snapchats chat action menu round background color" - }, - "camera_grid_lines": { - "name": "Camera Gridlines Color", - "description": "Changes Snapchats Gridlines color on the Camera Preview\nNote: Enable the grid on the my camera settings" - } - } - } - } + "custom_theme": { + "name": "Custom Theme", + "description": "Customize Snapchat's Colors\nNote: if you choose a dark theme (like Amoled), you may need to enable the dark mode in Snapchat settings for better results" }, "friend_feed_message_preview": { "name": "Friend Feed Message Preview", @@ -1201,20 +1159,11 @@ "always_light": "Always Light", "always_dark": "Always Dark" }, - "theme_picker": { - "amoled_dark_mode": "AMOLED Dark Mode", - "custom": "Custom Colors", + "custom_theme": { + "amoled_dark_mode": "Amoled Dark Mode", + "custom": "Custom Themes (Use the Quick Actions to manage themes)", "material_you_light": "Material You Light (Android 12+)", - "material_you_dark": "Material You Dark (Android 12+)", - "light_blue": "Light Blue", - "dark_blue": "Dark Blue", - "earthy_autumn": "Earthy Autumn", - "mint_chocolate": "Mint Chocolate", - "ginger_snap": "Ginger Snap", - "lemon_meringue": "Lemon Meringue", - "lava_flow": "Lava Flow", - "ocean_fog": "Ocean Fog", - "alien_landscape": "Alien Landscape" + "material_you_dark": "Material You Dark (Android 12+)" }, "friend_feed_menu_buttons": { "auto_download": "\u2B07\uFE0F Auto Download", @@ -1681,5 +1630,48 @@ "date_input_invalid_year_range": "Invalid year", "date_input_invalid_not_allowed": "Invalid date", "date_range_input_invalid_range_input": "Invalid date range" + }, + + "theming_attributes": { + "sigColorTextPrimary": "Main Text Color", + "sigColorChatChat": "Main Friend Feed Text Color", + "sigColorBackgroundSurface": "Background Surface Color", + "sigColorChatPendingSending": "Secondary Friend Feed Text Color", + "sigColorChatSnapWithSound": "Snaps With Sound Text Color", + "sigColorChatSnapWithoutSound": "Snaps Without Sound Text Color", + "actionSheetDescriptionTextColor": "Action Menu Description Text Color", + "sigColorBackgroundMain": "Background Color", + "listBackgroundDrawable": "Conversation list Background", + "sigColorChatConversationsLine": "Conversations Line Color", + "actionSheetBackgroundDrawable": "Action Menu Background Color", + "actionSheetRoundedBackgroundDrawable": "Action Menu Round Background Color", + "sigColorIconPrimary": "Action Menu Icon Color", + "sigExceptionColorCameraGridLines": "Camera Gridlines Color", + "listDivider": "List Divider Color", + "sigColorIconSecondary": "Secondary Icon Color", + "itemShapeFillColor": "Item Shape Fill Color", + "ringColor": "Ring Color", + "ringStartColor": "Ring Start Color", + "sigColorLayoutPlaceholder": "Layout Placeholder Color", + "scButtonColor": "Snapchat Button Color", + "recipientPillBackgroundDrawable": "Recipient Pill Background", + "boxBackgroundColor": "Box Background Color", + "editTextColor": "Edit Text Color", + "chipBackgroundColor": "Chip Background Color", + "recipientInputStyle": "Recipient Input Style", + "rangeFillColor": "Range Fill Color", + "pstsIndicatorColor": "PSTS Indicator Color", + "pstsTabBackground": "PSTS Tab Background", + "pstsDividerColor": "PSTS Divider Color", + "tabTextColor": "Tab Text Color", + "statusBarForeground": "Status Bar Foreground Color", + "statusBarBackground": "Status Bar Background Color", + "strokeColor": "Stroke Color", + "storyReplayViewRingColor": "Story Replay View Ring Color", + "sigColorButtonPrimary": "Primary Button Color", + "sigColorBaseAppYellow": "Base App Yellow Color", + "sigColorBackgroundSurfaceTranslucent": "Translucent Background Surface Color", + "sigColorStoryRingFriendsFeedStoryRing": "Story Ring Friends Feed Story Ring Color", + "sigColorStoryRingDiscoverTabThumbnailStoryRing": "Story Ring Discover Tab Thumbnail Story Ring Color" } } 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 @@ -16,7 +16,8 @@ enum class FileHandleScope( INTERNAL("internal"), LOCALE("locale"), USER_IMPORT("user_import"), - COMPOSER("composer"); + COMPOSER("composer"), + THEME("theme"); companion object { fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name } @@ -31,8 +32,8 @@ enum class InternalFileHandleType( CONFIG("config", "config.json"), MAPPINGS("mappings", "mappings.json"), MESSAGE_LOGGER("message_logger", "message_logger.db", isDatabase = true), - PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"); - + PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"), + SIF("sif", "libsif.so"); fun resolve(context: Context): File = if (isDatabase) { context.getDatabasePath(fileName) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt @@ -39,7 +39,7 @@ class ConversationInfo( val usernames: List<String> ) -class TrackerLog( +data class TrackerLog( val id: Int, val timestamp: Long, val conversationId: String, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -31,7 +31,7 @@ class Experimental : ConfigContainer() { val composerLogs = boolean("composer_logs") } - class NativeHooks : ConfigContainer(hasGlobalState = true) { + class NativeHooks : ConfigContainer() { val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() } val disableBitmoji = boolean("disable_bitmoji") val customEmojiFont = string("custom_emoji_font") { @@ -40,7 +40,6 @@ class Experimental : ConfigContainer() { addFlags(ConfigFlag.USER_IMPORT) filenameFilter = { it.endsWith(".ttf") } } - val remapExecutable = boolean("remap_executable") { requireRestart(); addNotices(FeatureNotice.INTERNAL_BEHAVIOR, FeatureNotice.UNSTABLE) } } class E2EEConfig : ConfigContainer(hasGlobalState = true) { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -19,46 +19,18 @@ class UserInterfaceTweaks : ConfigContainer() { } - class ColorsConfig : ConfigContainer() { - val textColor = color("text_color") - val chatChatTextColor = color("chat_chat_text_color") - val pendingSendingTextColor = color("pending_sending_text_color") - val snapWithSoundTextColor = color("snap_with_sound_text_color") - val snapWithoutSoundTextColor = color("snap_without_sound_text_color") - val backgroundColor = color("background_color") - val backgroundColorSurface = color("background_color_surface") - val friendFeedConversationsLineColor = color("friend_feed_conversations_line_color") - val actionMenuBackgroundColor = color("action_menu_background_color") - val actionMenuRoundBackgroundColor = color("action_menu_round_background_color") - val cameraGridLines = color("camera_grid_lines") - } - - inner class CustomizeUIConfig : ConfigContainer() { - val themePicker = unique("theme_picker", - "custom", - "amoled_dark_mode", - "material_you_light", - "material_you_dark", - "light_blue", - "dark_blue", - "earthy_autumn", - "mint_chocolate", - "ginger_snap", - "lemon_meringue", - "lava_flow", - "ocean_fog", - "alien_landscape", - ) - val colors = container("colors", ColorsConfig()) { requireRestart() } - } - val friendFeedMenuButtons = multiple( "friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() ).apply { set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) } val autoCloseFriendFeedMenu = boolean("auto_close_friend_feed_menu") - val customizeUi = container("customize_ui", CustomizeUIConfig()) { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + val customTheme = unique("custom_theme", + "amoled_dark_mode", + "material_you_light", + "material_you_dark", + "custom", + ) { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() } val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt @@ -0,0 +1,120 @@ +package me.rhunk.snapenhance.common.data + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class ThemeColorEntry( + @SerializedName("key") + val key: String, + @SerializedName("value") + var value: Int, +): Parcelable + +@Parcelize +data class DatabaseThemeContent( + @SerializedName("colors") + val colors: List<ThemeColorEntry> = emptyList(), +): Parcelable + +data class DatabaseTheme( + val id: Int, + val enabled: Boolean, + val name: String, + val description: String?, + val version: String?, + val author: String?, + val updateUrl: String?, +) { + fun toExportedTheme(content: DatabaseThemeContent): ExportedTheme { + return ExportedTheme( + name = name, + description = description, + version = version, + author = author, + content = content, + ) + } +} + +data class ExportedTheme( + val name: String, + val description: String?, + val version: String?, + val author: String?, + val content: DatabaseThemeContent, +) { + fun toDatabaseTheme(id: Int = -1, updateUrl: String? = null, enabled: Boolean = false): DatabaseTheme { + return DatabaseTheme( + id = id, + enabled = enabled, + name = name, + description = description, + version = version, + author = author, + updateUrl = updateUrl, + ) + } +} + +data class RepositoryThemeManifest( + val name: String, + val author: String?, + val description: String?, + val version: String?, + val filepath: String, +) + +data class RepositoryIndex( + val themes: List<RepositoryThemeManifest> = emptyList(), +) + +enum class ThemingAttributeType { + COLOR +} + +val AvailableThemingAttributes = mapOf( + ThemingAttributeType.COLOR to listOf( + "sigColorTextPrimary", + "sigColorBackgroundSurface", + "sigColorBackgroundMain", + "actionSheetBackgroundDrawable", + "actionSheetRoundedBackgroundDrawable", + "sigColorChatChat", + "sigColorChatPendingSending", + "sigColorChatSnapWithSound", + "sigColorChatSnapWithoutSound", + "sigExceptionColorCameraGridLines", + "listDivider", + "listBackgroundDrawable", + "sigColorIconPrimary", + "actionSheetDescriptionTextColor", + "ringColor", + "sigColorIconSecondary", + "itemShapeFillColor", + "ringStartColor", + "sigColorLayoutPlaceholder", + "scButtonColor", + "recipientPillBackgroundDrawable", + "boxBackgroundColor", + "editTextColor", + "chipBackgroundColor", + "recipientInputStyle", + "rangeFillColor", + "pstsIndicatorColor", + "pstsTabBackground", + "pstsDividerColor", + "tabTextColor", + "statusBarForeground", + "statusBarBackground", + "strokeColor", + "storyReplayViewRingColor", + "sigColorButtonPrimary", + "sigColorBaseAppYellow", + "sigColorBackgroundSurfaceTranslucent", + "sigColorStoryRingFriendsFeedStoryRing", + "sigColorStoryRingDiscoverTabThumbnailStoryRing", + ) +) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/TextFieldColors.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/TextFieldColors.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + + +@Composable +fun transparentTextFieldColors() = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary +) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt @@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = } fun Context.copyToClipboard(data: String, label: String = "Copied Text") { - getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText(label, data)) + runCatching { + getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(label, data)) + } +} + +fun Context.getTextFromClipboard(): String? { + return runCatching { + getSystemService(android.content.ClipboardManager::class.java).primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.text?.toString() + }.getOrNull() +} + +fun Context.getUrlFromClipboard(): String? { + return getTextFromClipboard()?.takeIf { it.startsWith("http") } } fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -153,13 +153,11 @@ class ModContext( } fun reloadNativeConfig() { - if (config.experimental.nativeHooks.globalState != true) return native.loadNativeConfig( NativeConfig( disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), disableMetrics = config.global.disableMetrics.get(), composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true, - remapExecutable = config.experimental.nativeHooks.remapExecutable.get(), customEmojiFontPath = getCustomEmojiFontPath(this) ) ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -4,7 +4,8 @@ import android.app.Activity import android.content.Context import android.content.res.Resources import android.os.Build -import dalvik.system.BaseDexClassLoader +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -14,6 +15,9 @@ import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.toWrapper import me.rhunk.snapenhance.common.data.FriendStreaks import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo @@ -27,7 +31,6 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater import me.rhunk.snapenhance.core.util.hook.HookAdapter import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.hook.hookConstructor import kotlin.reflect.KClass import kotlin.system.exitProcess import kotlin.system.measureTimeMillis @@ -166,6 +169,8 @@ class SnapEnhance { } } + private var safeMode = false + private fun onActivityCreate(activity: Activity) { measureTimeMillis { with(appContext) { @@ -173,6 +178,10 @@ class SnapEnhance { inAppOverlay.onActivityCreate(activity) scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) } actionManager.onActivityCreate() + + if (safeMode) { + appContext.inAppOverlay.showStatusToast(Icons.Outlined.Cancel, "Failed to load security features! Snapchat may not work properly.", durationMs = 5000) + } } }.also { time -> appContext.log.verbose("onActivityCreate took $time") @@ -180,36 +189,58 @@ class SnapEnhance { } private fun initNative() { - // don't initialize native when not logged in - if ( - !appContext.isLoggedIn() && - appContext.bridgeClient.getDebugProp("force_native_load", null) != "true" - ) return - if (appContext.config.experimental.nativeHooks.globalState != true) return - - lateinit var unhook: () -> Unit + val lateInit = appContext.native.initOnce { + nativeUnaryCallCallback = { request -> + appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) { + request.buffer = buffer + request.canceled = canceled + } + } + appContext.reloadNativeConfig() + } + + if (appContext.bridgeClient.getDebugProp("disable_sif", "false") != "true") { + runCatching { + appContext.native.loadSharedLibrary( + appContext.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.SIF.key) + .toWrapper() + .readBytes() + .takeIf { + it.isNotEmpty() + } ?: throw IllegalStateException("buffer is empty") + ) + appContext.log.verbose("loaded sif") + }.onFailure { + safeMode = true + appContext.log.error("Failed to load sif", it) + } + } else { + appContext.log.warn("sif is disabled") + } + Runtime::class.java.declaredMethods.first { it.name == "loadLibrary0" && it.parameterTypes.contentEquals( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Class::class.java, String::class.java) else arrayOf(ClassLoader::class.java, String::class.java) ) - }.hook(HookStage.AFTER) { param -> - val libName = param.arg<String>(1) - if (libName != "client") return@hook - unhook() - appContext.native.initOnce { - nativeUnaryCallCallback = { request -> - appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) { - request.buffer = buffer - request.canceled = canceled - } + }.apply { + if (safeMode) { + hook(HookStage.BEFORE) { param -> + if (param.arg<String>(1) != "scplugin") return@hook + appContext.log.warn("Can't load scplugin in safe mode") + Thread.sleep(Long.MAX_VALUE) } - appContext.reloadNativeConfig() - } - BaseDexClassLoader::class.java.hookConstructor(HookStage.AFTER) { - appContext.native.hideAnonymousDexFiles() } - }.also { unhook = { it.unhook() } } + + lateinit var unhook: () -> Unit + hook(HookStage.AFTER) { param -> + val libName = param.arg<String>(1) + if (libName != "client") return@hook + unhook() + appContext.log.verbose("libclient lateInit") + lateInit() + }.also { unhook = { it.unhook() } } + } } private fun initConfigListener() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -122,7 +122,7 @@ class FeatureManager( AccountSwitcher(), RemoveGroupsLockedStatus(), BypassMessageActionRestrictions(), - CustomizeUI(), + CustomTheming(), BetterLocation(), MediaFilePicker(), HideActiveMusic(), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt @@ -11,7 +11,7 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook -class DeviceSpooferHook: Feature("device_spoofer") { +class DeviceSpooferHook: Feature("Device Spoofer") { private fun hookInstallerPackageName() { context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param -> param.setResult("com.android.vending") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomTheming.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomTheming.kt @@ -0,0 +1,130 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.content.res.TypedArray +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import android.util.TypedValue +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.ui.graphics.toArgb +import com.google.gson.reflect.TypeToken +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.data.DatabaseThemeContent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import me.rhunk.snapenhance.core.util.ktx.getObjectField + +class CustomTheming: Feature("Custom Theming") { + private fun getAttribute(name: String): Int { + return context.resources.getIdentifier(name, "attr") + } + + private fun parseAttributeList(vararg attributes: Pair<String, Number>): Map<Int, Int> { + return attributes.toMap().mapKeys { + getAttribute(it.key) + }.filterKeys { it != 0 }.mapValues { + it.value.toInt() + } + } + + override fun init() { + val customThemeName = context.config.userInterface.customTheme.getNullable() ?: return + var currentTheme = mapOf<Int, Int>() // resource id -> color + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val colorScheme = dynamicDarkColorScheme(context.androidContext) + val light = customThemeName == "material_you_light" + val surfaceVariant = (if (light) colorScheme.surfaceVariant else colorScheme.onSurfaceVariant).toArgb() + val background = (if (light) colorScheme.onBackground else colorScheme.background).toArgb() + + currentTheme = parseAttributeList( + "sigColorTextPrimary" to surfaceVariant, + "sigColorChatChat" to surfaceVariant, + "sigColorChatPendingSending" to surfaceVariant, + "sigColorChatSnapWithSound" to surfaceVariant, + "sigColorChatSnapWithoutSound" to surfaceVariant, + "sigColorBackgroundMain" to background, + "sigColorBackgroundSurface" to background, + "listDivider" to colorScheme.onPrimary.copy(alpha = 0.12f).toArgb(), + "actionSheetBackgroundDrawable" to background, + "actionSheetRoundedBackgroundDrawable" to background, + "sigExceptionColorCameraGridLines" to background, + ) + } + + if (customThemeName == "amoled_dark_mode") { + currentTheme = parseAttributeList( + "sigColorTextPrimary" to 0xFFFFFFFF, + "sigColorChatChat" to 0xFFFFFFFF, + "sigColorChatPendingSending" to 0xFFFFFFFF, + "sigColorChatSnapWithSound" to 0xFFFFFFFF, + "sigColorChatSnapWithoutSound" to 0xFFFFFFFF, + "sigColorBackgroundMain" to 0xFF000000, + "sigColorBackgroundSurface" to 0xFF000000, + "listDivider" to 0xFF000000, + "actionSheetBackgroundDrawable" to 0xFF000000, + "actionSheetRoundedBackgroundDrawable" to 0xFF000000, + "sigExceptionColorCameraGridLines" to 0xFF000000, + ) + } + + if (customThemeName == "custom") { + val availableThemes = context.fileHandlerManager.getFileHandle(FileHandleScope.THEME.key, "")?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd -> + AutoCloseInputStream(pfd).use { it.readBytes() } + }?.let { + context.gson.fromJson(it.toString(Charsets.UTF_8), object: TypeToken<List<DatabaseThemeContent>>() {}) + } ?: run { + context.log.verbose("no custom themes found") + return + } + + val customThemeColors = mutableMapOf<Int, Int>() + + context.log.verbose("loading ${availableThemes.size} custom themes") + + availableThemes.forEach { themeContent -> + themeContent.colors.forEach colors@{ colorEntry -> + customThemeColors[getAttribute(colorEntry.key).takeIf { it != 0 }.also { + if (it == null) { + context.log.warn("unknown color attribute: ${colorEntry.key}") + } + } ?: return@colors] = colorEntry.value + } + } + + currentTheme = customThemeColors + + context.log.verbose("loaded ${customThemeColors.size} custom theme colors") + } + + onNextActivityCreate { + if (currentTheme.isEmpty()) return@onNextActivityCreate + + context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook( + HookStage.AFTER) { param -> + val array = param.arg<IntArray>(0) + val customColor = (currentTheme[array[0]] as? Number)?.toInt() ?: return@hook + + val result = param.getResult() as TypedArray + val typedArrayData = result.getObjectField("mData") as IntArray + + when (val attributeType = result.getType(0)) { + TypedValue.TYPE_INT_COLOR_ARGB8, TypedValue.TYPE_INT_COLOR_RGB8, TypedValue.TYPE_INT_COLOR_ARGB4, TypedValue.TYPE_INT_COLOR_RGB4 -> { + typedArrayData[1] = customColor // index + STYLE_DATA + } + TypedValue.TYPE_STRING -> { + val stringValue = result.getString(0) + if (stringValue?.endsWith(".xml") == true) { + typedArrayData[0] = TypedValue.TYPE_INT_COLOR_ARGB4 // STYLE_TYPE + typedArrayData[1] = customColor // STYLE_DATA + typedArrayData[5] = 0; // STYLE_DENSITY + } + } + else -> context.log.warn("unknown attribute type: ${attributeType.toString(16)}") + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomizeUI.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomizeUI.kt @@ -1,211 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.ui - -import android.content.res.TypedArray -import android.graphics.drawable.ColorDrawable -import android.os.Build -import android.util.TypedValue -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.ui.graphics.toArgb -import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.util.hook.HookStage -import me.rhunk.snapenhance.core.util.hook.Hooker -import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.ktx.getIdentifier - -class CustomizeUI: Feature("Customize UI") { - private fun getAttribute(name: String): Int { - return context.resources.getIdentifier(name, "attr") - } - - override fun init() { - val customizeUIConfig = context.config.userInterface.customizeUi - val themePicker = customizeUIConfig.themePicker.getNullable() ?: return - val colorsConfig = context.config.userInterface.customizeUi.colors - - if (themePicker == "custom") { - themes.clear() - themes[themePicker] = mapOf( - "sigColorTextPrimary" to colorsConfig.textColor.getNullable(), - "sigColorChatChat" to colorsConfig.chatChatTextColor.getNullable(), - "sigColorChatPendingSending" to colorsConfig.pendingSendingTextColor.getNullable(), - "sigColorChatSnapWithSound" to colorsConfig.snapWithSoundTextColor.getNullable(), - "sigColorChatSnapWithoutSound" to colorsConfig.snapWithoutSoundTextColor.getNullable(), - "sigColorBackgroundMain" to colorsConfig.backgroundColor.getNullable(), - "listDivider" to colorsConfig.friendFeedConversationsLineColor.getNullable(), - "sigColorBackgroundSurface" to colorsConfig.backgroundColorSurface.getNullable(), - "actionSheetBackgroundDrawable" to colorsConfig.actionMenuBackgroundColor.getNullable(), - "actionSheetRoundedBackgroundDrawable" to colorsConfig.actionMenuRoundBackgroundColor.getNullable(), - "sigExceptionColorCameraGridLines" to colorsConfig.cameraGridLines.getNullable(), - ).filterValues { it != null }.map { (key, value) -> - getAttribute(key) to value!! - }.toMap() - } - if (themePicker == "material_you_light" || themePicker == "material_you_dark") { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val colorScheme = dynamicDarkColorScheme(context.androidContext) - val light = themePicker == "material_you_light" - themes.clear() - val surfaceVariant = (if (light) colorScheme.surfaceVariant else colorScheme.onSurfaceVariant).toArgb() - val background = (if (light) colorScheme.onBackground else colorScheme.background).toArgb() - - themes[themePicker] = mapOf( - "sigColorTextPrimary" to surfaceVariant, - "sigColorChatChat" to surfaceVariant, - "sigColorChatPendingSending" to surfaceVariant, - "sigColorChatSnapWithSound" to surfaceVariant, - "sigColorChatSnapWithoutSound" to surfaceVariant, - "sigColorBackgroundMain" to background, - "sigColorBackgroundSurface" to background, - "listDivider" to colorScheme.onPrimary.copy(alpha = 0.12f).toArgb(), - "actionSheetBackgroundDrawable" to background, - "actionSheetRoundedBackgroundDrawable" to background, - "sigExceptionColorCameraGridLines" to background, - ).map { getAttribute(it.key) to it.value }.toMap() - } - } - - context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook( - HookStage.AFTER) { param -> - val array = param.arg<IntArray>(0) - val result = param.getResult() as TypedArray - - fun ephemeralHook(methodName: String, content: Any) { - Hooker.ephemeralHookObjectMethod(result::class.java, result, methodName, HookStage.BEFORE) { - it.setResult(content) - } - } - - themes[themePicker]?.get(array[0])?.let { value -> - when (val attributeType = result.getType(0)) { - TypedValue.TYPE_INT_COLOR_ARGB8, TypedValue.TYPE_INT_COLOR_RGB8, TypedValue.TYPE_INT_COLOR_ARGB4, TypedValue.TYPE_INT_COLOR_RGB4 -> { - ephemeralHook("getColor", (value as Number).toInt()) - } - TypedValue.TYPE_STRING -> { - val stringValue = result.getString(0) - if (stringValue?.endsWith(".xml") == true) { - ephemeralHook("getDrawable", ColorDrawable((value as Number).toInt())) - } - } - else -> context.log.warn("unknown attribute type: ${attributeType.toString(16)}") - } - } - } - } - - private val themes by lazy { - mapOf( - "amoled_dark_mode" to mapOf( - "sigColorTextPrimary" to 0xFFFFFFFF, - "sigColorBackgroundMain" to 0xFF000000, - "sigColorBackgroundSurface" to 0xFF000000, - "listDivider" to 0xFF000000, - "actionSheetBackgroundDrawable" to 0xFF000000, - "actionSheetRoundedBackgroundDrawable" to 0xFF000000 - ), - "light_blue" to mapOf( - "sigColorTextPrimary" to 0xFF03BAFC, - "sigColorBackgroundMain" to 0xFFBDE6FF, - "sigColorBackgroundSurface" to 0xFF78DBFF, - "listDivider" to 0xFFBDE6FF, - "actionSheetBackgroundDrawable" to 0xFF78DBFF, - "sigColorChatChat" to 0xFF08D6FF, - "sigColorChatPendingSending" to 0xFF08D6FF, - "sigColorChatSnapWithSound" to 0xFF08D6FF, - "sigColorChatSnapWithoutSound" to 0xFF08D6FF, - "sigExceptionColorCameraGridLines" to 0xFF08D6FF - ), - "dark_blue" to mapOf( - "sigColorTextPrimary" to 0xFF98C2FD, - "sigColorBackgroundMain" to 0xFF192744, - "sigColorBackgroundSurface" to 0xFF192744, - "actionSheetBackgroundDrawable" to 0xFF192744, - "sigColorChatChat" to 0xFF98C2FD, - "sigColorChatPendingSending" to 0xFF98C2FD, - "sigColorChatSnapWithSound" to 0xFF98C2FD, - "sigColorChatSnapWithoutSound" to 0xFF98C2FD, - "sigExceptionColorCameraGridLines" to 0xFF192744 - ), - "earthy_autumn" to mapOf( - "sigColorTextPrimary" to 0xFFF7CAC9, - "sigColorBackgroundMain" to 0xFF800000, - "sigColorBackgroundSurface" to 0xFF800000, - "actionSheetBackgroundDrawable" to 0xFF800000, - "sigColorChatChat" to 0xFFF7CAC9, - "sigColorChatPendingSending" to 0xFFF7CAC9, - "sigColorChatSnapWithSound" to 0xFFF7CAC9, - "sigColorChatSnapWithoutSound" to 0xFFF7CAC9, - "sigExceptionColorCameraGridLines" to 0xFF800000 - ), - "mint_chocolate" to mapOf( - "sigColorTextPrimary" to 0xFFFFFFFF, - "sigColorBackgroundMain" to 0xFF98FF98, - "sigColorBackgroundSurface" to 0xFF98FF98, - "actionSheetBackgroundDrawable" to 0xFF98FF98, - "sigColorChatChat" to 0xFFFFFFFF, - "sigColorChatPendingSending" to 0xFFFFFFFF, - "sigColorChatSnapWithSound" to 0xFFFFFFFF, - "sigColorChatSnapWithoutSound" to 0xFFFFFFFF, - "sigExceptionColorCameraGridLines" to 0xFF98FF98 - ), - "ginger_snap" to mapOf( - "sigColorTextPrimary" to 0xFFFFFFFF, - "sigColorBackgroundMain" to 0xFFC6893A, - "sigColorBackgroundSurface" to 0xFFC6893A, - "actionSheetBackgroundDrawable" to 0xFFC6893A, - "sigColorChatChat" to 0xFFFFFFFF, - "sigColorChatPendingSending" to 0xFFFFFFFF, - "sigColorChatSnapWithSound" to 0xFFFFFFFF, - "sigColorChatSnapWithoutSound" to 0xFFFFFFFF, - "sigExceptionColorCameraGridLines" to 0xFFC6893A - ), - "lemon_meringue" to mapOf( - "sigColorTextPrimary" to 0xFF000000, - "sigColorBackgroundMain" to 0xFFFCFFE7, - "sigColorBackgroundSurface" to 0xFFFCFFE7, - "actionSheetBackgroundDrawable" to 0xFFFCFFE7, - "sigColorChatChat" to 0xFF000000, - "sigColorChatPendingSending" to 0xFF000000, - "sigColorChatSnapWithSound" to 0xFF000000, - "sigColorChatSnapWithoutSound" to 0xFF000000, - "sigExceptionColorCameraGridLines" to 0xFFFCFFE7 - ), - "lava_flow" to mapOf( - "sigColorTextPrimary" to 0xFFFFCC00, - "sigColorBackgroundMain" to 0xFFC70039, - "sigColorBackgroundSurface" to 0xFFC70039, - "actionSheetBackgroundDrawable" to 0xFFC70039, - "sigColorChatChat" to 0xFFFFCC00, - "sigColorChatPendingSending" to 0xFFFFCC00, - "sigColorChatSnapWithSound" to 0xFFFFCC00, - "sigColorChatSnapWithoutSound" to 0xFFFFCC00, - "sigExceptionColorCameraGridLines" to 0xFFC70039 - ), - "ocean_fog" to mapOf( - "sigColorTextPrimary" to 0xFF333333, - "sigColorBackgroundMain" to 0xFFB0C4DE, - "sigColorBackgroundSurface" to 0xFFB0C4DE, - "actionSheetBackgroundDrawable" to 0xFFB0C4DE, - "sigColorChatChat" to 0xFF333333, - "sigColorChatPendingSending" to 0xFF333333, - "sigColorChatSnapWithSound" to 0xFF333333, - "sigColorChatSnapWithoutSound" to 0xFF333333, - "sigExceptionColorCameraGridLines" to 0xFFB0C4DE - ), - "alien_landscape" to mapOf( - "sigColorTextPrimary" to 0xFFFFFFFF, - "sigColorBackgroundMain" to 0xFF9B59B6, - "sigColorBackgroundSurface" to 0xFF9B59B6, - "actionSheetBackgroundDrawable" to 0xFF9B59B6, - "sigColorChatChat" to 0xFFFFFFFF, - "sigColorChatPendingSending" to 0xFFFFFFFF, - "sigColorChatSnapWithSound" to 0xFFFFFFFF, - "sigColorChatSnapWithoutSound" to 0xFFFFFFFF, - "sigExceptionColorCameraGridLines" to 0xFF9B59B6 - ) - ).mapValues { (_, attributes) -> - attributes.map { (key, value) -> - getAttribute(key) to value as Any - }.toMap() - }.toMutableMap() - } -} diff --git a/native/jni/src/common.h b/native/jni/src/common.h @@ -13,7 +13,6 @@ typedef struct { bool disable_bitmoji; bool disable_metrics; bool composer_hooks; - bool remap_executable; char custom_emoji_font_path[256]; } native_config_t; diff --git a/native/jni/src/dobby_helper.h b/native/jni/src/dobby_helper.h @@ -9,8 +9,5 @@ static pthread_mutex_t hook_mutex = PTHREAD_MUTEX_INITIALIZER; static void inline SafeHook(void *addr, void *hook, void **original) { pthread_mutex_lock(&hook_mutex); DobbyHook(addr, hook, original); - if (common::native_config->remap_executable) { - mprotect((void *)((uintptr_t) *original & PAGE_MASK), PAGE_SIZE, PROT_EXEC); - } pthread_mutex_unlock(&hook_mutex); } \ No newline at end of file diff --git a/native/jni/src/hooks/linker_hook.h b/native/jni/src/hooks/linker_hook.h @@ -0,0 +1,54 @@ +#pragma once + +#include <map> + +namespace LinkerHook { + static auto linker_openat_hooks = std::map<std::string, std::pair<uintptr_t, size_t>>(); + + void JNICALL addLinkerSharedLibrary(JNIEnv *env, jobject, jstring path, jbyteArray content) { + const char *path_str = env->GetStringUTFChars(path, nullptr); + jsize content_len = env->GetArrayLength(content); + jbyte *content_ptr = env->GetByteArrayElements(content, nullptr); + + auto allocated_content = (jbyte *) malloc(content_len); + memcpy(allocated_content, content_ptr, content_len); + linker_openat_hooks[path_str] = std::make_pair((uintptr_t) allocated_content, content_len); + + LOGD("added linker hook for %s, size=%d", path_str, content_len); + + env->ReleaseStringUTFChars(path, path_str); + env->ReleaseByteArrayElements(content, content_ptr, JNI_ABORT); + } + + HOOK_DEF(int, linker_openat, int dirfd, const char *pathname, int flags, mode_t mode) { + for (const auto &item: linker_openat_hooks) { + if (strstr(pathname, item.first.c_str())) { + LOGD("found openat hook for %s", pathname); + static auto memfd_create = (int (*)(const char *, unsigned int)) DobbySymbolResolver("libc.so", "memfd_create"); + auto fd = memfd_create("me.rhunk.snapenhance", 0); + LOGD("memfd created: %d", fd); + + if (fd == -1) { + LOGE("memfd_create failed: %d", errno); + return -1; + } + if (write(fd, (void *) item.second.first, item.second.second) == -1) { + LOGE("write failed: %d", errno); + return -1; + } + lseek(fd, 0, SEEK_SET); + + free((void *) item.second.first); + linker_openat_hooks.erase(item.first); + + LOGD("memfd written"); + return fd; + } + } + return linker_openat_original(dirfd, pathname, flags, mode); + } + + void init() { + DobbyHook((void *) DobbySymbolResolver(ARM64 ? "linker64" : "linker", "__dl___openat"), (void *) linker_openat, (void **) &linker_openat_original); + } +}+ \ No newline at end of file diff --git a/native/jni/src/library.cpp b/native/jni/src/library.cpp @@ -7,6 +7,7 @@ #include "logger.h" #include "common.h" #include "dobby_helper.h" +#include "hooks/linker_hook.h" #include "hooks/unary_call.h" #include "hooks/fstat_hook.h" #include "hooks/sqlite_mutex.h" @@ -17,9 +18,6 @@ bool JNICALL init(JNIEnv *env, jobject clazz) { LOGD("Initializing native"); using namespace common; - util::remap_sections([](const std::string &line, size_t size) { - return line.find(BUILD_PACKAGE) != std::string::npos; - }, native_config->remap_executable); native_lib_object = env->NewGlobalRef(clazz); client_module = util::get_module("libclient.so"); @@ -66,7 +64,6 @@ void JNICALL load_config(JNIEnv *env, jobject, jobject config_object) { native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji"); native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics"); native_config->composer_hooks = GET_CONFIG_BOOL("composerHooks"); - native_config->remap_executable = GET_CONFIG_BOOL("remapExecutable"); memset(native_config->custom_emoji_font_path, 0, sizeof(native_config->custom_emoji_font_path)); auto custom_emoji_font_path = env->GetObjectField(config_object, env->GetFieldID(native_config_clazz, "customEmojiFontPath", "Ljava/lang/String;")); @@ -97,15 +94,6 @@ void JNICALL lock_database(JNIEnv *env, jobject, jstring database_name, jobject } } -void JNICALL hide_anonymous_dex_files(JNIEnv *, jobject) { - util::remap_sections([](const std::string &line, size_t size) { - return ( - (common::native_config->remap_executable && size == PAGE_SIZE && line.find("r-xp 00000000 00") != std::string::npos && line.find("[v") == std::string::npos) || - line.find("dalvik-DEX") != std::string::npos || - line.find("dalvik-classes") != std::string::npos - ); - }, common::native_config->remap_executable); -} extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { common::java_vm = vm; @@ -118,7 +106,9 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { methods.push_back({"lockDatabase", "(Ljava/lang/String;Ljava/lang/Runnable;)V", (void *)lock_database}); methods.push_back({"setComposerLoader", "(Ljava/lang/String;)V", (void *) ComposerHook::setComposerLoader}); methods.push_back({"composerEval", "(Ljava/lang/String;)Ljava/lang/String;",(void *) ComposerHook::composerEval}); - methods.push_back({"hideAnonymousDexFiles", "()V", (void *)hide_anonymous_dex_files}); + methods.push_back({"addLinkerSharedLibrary", "(Ljava/lang/String;[B)V", (void *) LinkerHook::addLinkerSharedLibrary}); + + LinkerHook::init(); env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size()); return JNI_VERSION_1_6; diff --git a/native/jni/src/util.h b/native/jni/src/util.h @@ -52,46 +52,6 @@ namespace util { return { start_offset, end_offset - start_offset }; } - static void remap_sections(std::function<bool(const std::string &, size_t)> filter, bool remove_read_permission) { - char buff[256]; - auto maps = fopen("/proc/self/maps", "rt"); - - while (fgets(buff, sizeof buff, maps) != NULL) { - int len = strlen(buff); - if (len > 0 && buff[len - 1] == '\n') buff[--len] = '\0'; - - size_t start, end, offset; - char flags[4]; - - if (sscanf(buff, "%zx-%zx %c%c%c%c %zx", &start, &end, - &flags[0], &flags[1], &flags[2], &flags[3], &offset) != 7) continue; - - if (!filter(buff, end - start)) continue; - - auto section_size = end - start; - auto section_ptr = mmap(0, section_size, PROT_READ | PROT_EXEC | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - - if (section_ptr == MAP_FAILED) { - LOGE("mmap failed: %s", strerror(errno)); - break; - } - - memcpy(section_ptr, (void *)start, section_size); - - if (mremap(section_ptr, section_size, section_size, MREMAP_MAYMOVE | MREMAP_FIXED, start) == MAP_FAILED) { - LOGE("mremap failed: %s", strerror(errno)); - break; - } - - auto new_prot = (flags[0] == 'r' ? PROT_READ : 0) | (flags[1] == 'w' ? PROT_WRITE : 0) | (flags[2] == 'x' ? PROT_EXEC : 0); - if (remove_read_permission && flags[0] == 'r' && flags[2] == 'x') { - new_prot &= ~PROT_READ; - } - mprotect((void *)start, section_size, new_prot); - } - fclose(maps); - } - static uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) { std::vector<char> bytes; std::vector<char> mask; diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt @@ -8,7 +8,5 @@ data class NativeConfig( @JvmField val composerHooks: Boolean = false, @JvmField - val remapExecutable: Boolean = false, - @JvmField val customEmojiFontPath: String? = null, ) \ No newline at end of file diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt @@ -1,6 +1,9 @@ package me.rhunk.snapenhance.nativelib +import android.annotation.SuppressLint import android.util.Log +import kotlin.math.absoluteValue +import kotlin.random.Random @Suppress("KotlinJniMissingFunction") class NativeLib { @@ -11,19 +14,21 @@ class NativeLib { private set } - fun initOnce(callback: NativeLib.() -> Unit) { + fun initOnce(callback: NativeLib.() -> Unit): () -> Unit { if (initialized) throw IllegalStateException("NativeLib already initialized") - runCatching { + return runCatching { System.loadLibrary(BuildConfig.NATIVE_NAME) initialized = true callback(this) - if (!init()) { - throw IllegalStateException("NativeLib init failed. Check logcat for more info") + return@runCatching { + if (!init()) { + throw IllegalStateException("NativeLib init failed. Check logcat for more info") + } } }.onFailure { initialized = false Log.e("SnapEnhance", "NativeLib init failed", it) - } + }.getOrThrow() } @Suppress("unused") @@ -54,10 +59,18 @@ class NativeLib { } } + @SuppressLint("UnsafeDynamicallyLoadedCode") + fun loadSharedLibrary(content: ByteArray) { + if (!initialized) throw IllegalStateException("NativeLib not initialized") + val generatedPath = "/data/app/${Random.nextLong().absoluteValue.toString(16)}.so" + addLinkerSharedLibrary(generatedPath, content) + System.load(generatedPath) + } + private external fun init(): Boolean private external fun loadConfig(config: NativeConfig) private external fun lockDatabase(name: String, callback: Runnable) external fun setComposerLoader(code: String) external fun composerEval(code: String): String? - external fun hideAnonymousDexFiles() + private external fun addLinkerSharedLibrary(path: String, content: ByteArray) } \ No newline at end of file