Merge branch 'refs/heads/refactor_ex' into refactor

This commit is contained in:
rhunk 2024-07-17 12:55:25 +02:00
commit 12ad30ffd8
43 changed files with 2290 additions and 490 deletions

View File

@ -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
}
}

View File

@ -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")
}
}
}

View File

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

View File

@ -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()
})
}

View File

@ -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",
),
))
}
}

View File

@ -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)
})
}
}

View File

@ -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()))
}
}

View File

@ -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)

View File

@ -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) {

View File

@ -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")
}
}
}
}
}
}
}

View File

@ -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
modifier = Modifier
.clickable {
selectedFile =
if (selectedFile == file.name) null else file.name
propertyValue.setAny(selectedFile)
}.padding(5.dp),
}
.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()
)
}
}

View File

@ -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))

View File

@ -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(),

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
)
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -74,9 +74,12 @@ fun LogsTab(
suspend fun loadNewLogs() {
withContext(Dispatchers.IO) {
logs.addAll(getPaginatedLogs(pageIndex).apply {
getPaginatedLogs(pageIndex).let {
withContext(Dispatchers.Main) {
logs.addAll(it)
pageIndex += 1
})
}
}
}
}

View File

@ -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
}

View File

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

View File

@ -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")
}
}
}
}

View File

@ -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(
@ -463,6 +462,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<*>,

View File

@ -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,
)
}

View File

@ -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"
}
}

View File

@ -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)

View File

@ -39,7 +39,7 @@ class ConversationInfo(
val usernames: List<String>
)
class TrackerLog(
data class TrackerLog(
val id: Int,
val timestamp: Long,
val conversationId: String,

View File

@ -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) {

View File

@ -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() }

View File

@ -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",
)
)

View File

@ -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
)

View File

@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
}
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
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 {

View File

@ -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)
)
)

View File

@ -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,24 +189,7 @@ 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
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 {
val lateInit = appContext.native.initOnce {
nativeUnaryCallCallback = { request ->
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
request.buffer = buffer
@ -206,11 +198,50 @@ class SnapEnhance {
}
appContext.reloadNativeConfig()
}
BaseDexClassLoader::class.java.hookConstructor(HookStage.AFTER) {
appContext.native.hideAnonymousDexFiles()
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)
)
}.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)
}
}
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() {
val tasks = linkedSetOf<() -> Unit>()

View File

@ -122,7 +122,7 @@ class FeatureManager(
AccountSwitcher(),
RemoveGroupsLockedStatus(),
BypassMessageActionRestrictions(),
CustomizeUI(),
CustomTheming(),
BetterLocation(),
MediaFilePicker(),
HideActiveMusic(),

View File

@ -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")

View File

@ -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)}")
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -8,7 +8,5 @@ data class NativeConfig(
@JvmField
val composerHooks: Boolean = false,
@JvmField
val remapExecutable: Boolean = false,
@JvmField
val customEmojiFontPath: String? = null,
)

View File

@ -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)
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)
}