refactor: remove theming feature

Deprecated after SC v13.7.0.42

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk
2025-06-06 14:19:51 +02:00
parent ba848dfd9d
commit acc92ce907
12 changed files with 6 additions and 1476 deletions

View File

@@ -8,7 +8,6 @@ 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
@@ -115,12 +114,6 @@ 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

@@ -3,7 +3,6 @@ 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
@@ -22,7 +21,4 @@ enum class EnumQuickActions(
LOGGER_HISTORY("logger_history", Icons.Default.History, {
loggerHistory.navigateReset()
}),
THEMING("theming", Icons.Default.Palette, {
theming.navigateReset()
})
}

View File

@@ -1,126 +0,0 @@
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

@@ -11,23 +11,21 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.location.BetterLocationRoot
import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection
import me.rhunk.snapenhance.ui.manager.pages.TasksRootSection
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRootSection
import me.rhunk.snapenhance.ui.manager.pages.features.ManageRuleFeature
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
import me.rhunk.snapenhance.ui.manager.pages.home.HomeRootSection
import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings
import me.rhunk.snapenhance.ui.manager.pages.location.BetterLocationRoot
import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRootSection
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.features.ManageRuleFeature
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
@@ -63,8 +61,6 @@ 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())

View File

@@ -1,393 +0,0 @@
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

@@ -1,277 +0,0 @@
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.TextDecoration
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),
verticalArrangement = Arrangement.spacedBy(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),
) {
Text(
text = themeManifest.name,
maxLines = 1,
fontSize = 16.sp,
lineHeight = 10.sp,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
)
themeManifest.author?.let {
Text(
text = "by $it",
maxLines = 1,
fontSize = 10.sp,
lineHeight = 16.sp,
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Light,
overflow = TextOverflow.Visible,
)
}
themeManifest.description?.let {
Text(
text = it,
fontSize = 12.sp,
maxLines = 3,
lineHeight = 16.sp,
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")
}
}
}
}
}
}
}
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}

View File

@@ -1,462 +0,0 @@
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(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.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(),
onClick = {
routes.editTheme.navigate {
this["theme_id"] = theme.id.toString()
}
}
) {
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(200.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

@@ -37,8 +37,6 @@
"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",
@@ -189,9 +187,6 @@
"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": {
@@ -336,10 +331,6 @@
"logger_history": {
"name": "Logger History",
"description": "View the history of logged messages"
},
"theming": {
"name": "Theming",
"description": "Customize the look and feel of Snapchat"
}
},
@@ -454,10 +445,6 @@
"name": "Enable App Appearance Settings",
"description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions"
},
"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",
"description": "Shows a preview of the last messages in the Friend Feed",
@@ -1212,12 +1199,6 @@
"always_light": "Always Light",
"always_dark": "Always Dark"
},
"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+)"
},
"friend_feed_menu_buttons": {
"auto_download": "\u2B07\uFE0F Auto Download",
"auto_save": "\uD83D\uDCAC Auto Save Messages",
@@ -1708,48 +1689,6 @@
"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"
},
"send_override_dialog": {
"title": "Send media as {type}",
"duration": "Duration: {duration}",

View File

@@ -5,6 +5,8 @@ import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import android.os.ParcelFileDescriptor.AutoCloseOutputStream
import me.rhunk.snapenhance.bridge.storage.FileHandle
import me.rhunk.snapenhance.common.bridge.FileHandleScope.entries
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType.entries
import me.rhunk.snapenhance.common.util.LazyBridgeValue
import me.rhunk.snapenhance.common.util.lazyBridge
import java.io.File
@@ -16,8 +18,7 @@ enum class FileHandleScope(
INTERNAL("internal"),
LOCALE("locale"),
USER_IMPORT("user_import"),
COMPOSER("composer"),
THEME("theme");
COMPOSER("composer");
companion object {
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }

View File

@@ -27,12 +27,6 @@ class UserInterfaceTweaks : ConfigContainer() {
set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key))
}
val autoCloseFriendFeedMenu = boolean("auto_close_friend_feed_menu")
val customTheme = unique("custom_theme",
"amoled_dark_mode",
"material_you_light",
"material_you_dark",
"custom",
) { addNotices(FeatureNotice.UNSTABLE); requireRestart(); versionCheck = RES_OBF_VERSION_CHECK.copy(isDisabled = true) }
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

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

View File

@@ -1,130 +0,0 @@
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)}")
}
}
}
}
}