feat: custom theming

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk
2024-07-02 17:30:07 +02:00
parent d31591fd47
commit 2cb3db042f
19 changed files with 1311 additions and 361 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

@@ -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,15 @@ class AppDatabase(
"longitude DOUBLE",
"radius DOUBLE",
),
"themes" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"enabled BOOLEAN DEFAULT 0",
"name VARCHAR",
"version VARCHAR",
"author VARCHAR",
"updateUrl VARCHAR",
"content TEXT",
),
))
}
}

View File

@@ -0,0 +1,114 @@
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,
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,
version = cursor.getStringOrNull("version"),
author = cursor.getStringOrNull("author"),
updateUrl = cursor.getStringOrNull("updateUrl")
)
}
}
}
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("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,8 @@ 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.theming.ThemingRoot
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
@@ -58,6 +60,9 @@ 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 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

@@ -40,6 +40,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.config.*
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.*
@@ -177,11 +178,15 @@ class FeaturesRootSection : Routes.Route() {
.fillMaxWidth(),
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
@@ -200,10 +205,13 @@ class FeaturesRootSection : Routes.Route() {
}
items(files, key = { it.name }) { file ->
Row(
modifier = Modifier.clickable {
selectedFile = if (selectedFile == file.name) null else file.name
propertyValue.setAny(selectedFile)
}.padding(5.dp),
modifier = Modifier
.clickable {
selectedFile =
if (selectedFile == file.name) null else file.name
propertyValue.setAny(selectedFile)
}
.padding(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
@@ -321,23 +329,13 @@ class FeaturesRootSection : Routes.Route() {
DataProcessors.Type.INT_COLOR -> {
dialogComposable = {
alertDialogs.ColorPickerDialog(property) {
alertDialogs.ColorPickerPropertyDialog(property) {
showDialog = false
}
}
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
val selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }
AlphaTile(
modifier = Modifier
.size(30.dp)
.border(2.dp, Color.White, shape = RoundedCornerShape(15.dp))
.clip(RoundedCornerShape(15.dp)),
selectedColor = selectedColor ?: Color.Transparent,
tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent,
tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent,
tileSize = 8.dp,
)
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
}
}
@@ -489,14 +487,7 @@ class FeaturesRootSection : Routes.Route() {
.padding(end = 10.dp)
.height(70.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary
)
colors = transparentTextFieldColors()
)
}
}

View File

@@ -0,0 +1,382 @@
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[ThemingAttribute.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 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
theme.version?.let { themeVersion = it }
themeAuthor = theme.author ?: ""
themeUpdateUrl = theme.updateUrl ?: ""
}
}
}
val lazyListState = rememberLazyListState()
val themeContent by 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,
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(),
maxLines = 1
)
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 = 1,
value = themeVersion,
onValueChange = { themeVersion = it },
label = { Text("Version") },
colors = transparentTextFieldColors()
)
TextField(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
value = themeAuthor,
onValueChange = { themeAuthor = it },
label = { Text("Author") },
colors = transparentTextFieldColors()
)
TextField(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
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,402 @@
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.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.delay
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.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.*
import okhttp3.OkHttpClient
class ThemingRoot: Routes.Route() {
private val reloadDispatcher = AsyncUpdateDispatcher()
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val titles = listOf("Installed Themes", "Catalog")
private var currentPage by mutableIntStateOf(0)
private val okHttpClient by lazy { OkHttpClient() }
private fun exportTheme(theme: DatabaseTheme) {
context.coroutineScope.launch {
val themeJson = ExportedTheme(
name = theme.name,
version = theme.version ?: "",
author = theme.author ?: "",
content = 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(themeJson).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) {
reloadDispatcher.dispatch()
}
}
}
private suspend fun importTheme(content: String, url: String? = null) {
val theme = context.gson.fromJson(content, ExportedTheme::class.java)
val themeId = context.database.addOrUpdateTheme(
DatabaseTheme(
id = -1,
enabled = false,
name = theme.name,
version = theme.version,
author = theme.author,
updateUrl = url
)
)
context.database.setThemeContent(themeId, theme.content)
context.shortToast("Theme imported successfully")
withContext(Dispatchers.Main) {
reloadDispatcher.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 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)
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
},
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")
}
)
}
}
}
}
@Composable
private fun InstalledThemes() {
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.database.getThemeList()
}
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))
}
}
}
@Composable
private fun ThemeCatalog() {
val installedThemes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.database.getThemeList()
}
Text(text = "Not Implemented", modifier = Modifier.fillMaxWidth().padding(5.dp), textAlign = TextAlign.Center)
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
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()
}
}
}
}
}

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,8 @@
"friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule",
"file_imports": "File Imports",
"theming": "Theming",
"edit_theme": "Edit Theme",
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
@@ -168,6 +170,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": {
@@ -304,6 +309,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 +424,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 +1154,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 +1625,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 }

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,84 @@
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 version: String?,
val author: String?,
val updateUrl: String?,
)
data class ExportedTheme(
val name: String,
val version: String?,
val author: String?,
val content: DatabaseThemeContent,
)
enum class ThemingAttribute {
COLOR
}
val AvailableThemingAttributes = mapOf(
ThemingAttribute.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

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

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