Merge branch 'refs/heads/refactor_ex' into refactor

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

View File

@ -8,10 +8,28 @@ import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
import me.rhunk.snapenhance.storage.getEnabledThemesContent
import java.io.File import java.io.File
import java.io.OutputStream 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( class LocalFileHandle(
private val file: File private val file: File
): FileHandle.Stub() { ): FileHandle.Stub() {
@ -97,6 +115,12 @@ class RemoteFileHandleManager(
"composer/${name.substringAfterLast("/")}" "composer/${name.substringAfterLast("/")}"
) )
} }
FileHandleScope.THEME -> {
return ByteArrayFileHandle(
context,
context.gson.toJson(context.database.getEnabledThemesContent()).toByteArray(Charsets.UTF_8)
)
}
else -> return null else -> return null
} }
} }

View File

@ -0,0 +1,80 @@
package me.rhunk.snapenhance
import android.os.Build
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
class RemoteSharedLibraryManager(
private val remoteSideContext: RemoteSideContext
) {
private val okHttpClient = OkHttpClient()
private fun getVersion(): String? {
return runCatching {
okHttpClient.newCall(
Request.Builder()
.url("https://raw.githubusercontent.com/SnapEnhance/resources/main/sif/version")
.build()
).execute().use { response ->
if (!response.isSuccessful) {
return null
}
response.body.string()
}
}.getOrNull()
}
private fun downloadLatest(outputFile: File): Boolean {
val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: return false
val request = Request.Builder()
.url("https://raw.githubusercontent.com/SnapEnhance/resources/main/sif/$abi.so")
.build()
runCatching {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return false
}
response.body.byteStream().use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
return true
}
}.onFailure {
remoteSideContext.log.error("Failed to download latest sif", it)
}
return false
}
fun init() {
val libraryFile = InternalFileHandleType.SIF.resolve(remoteSideContext.androidContext)
val currentVersion = remoteSideContext.sharedPreferences.getString("sif", null)?.trim()
if (currentVersion == null || currentVersion == "false") {
libraryFile.takeIf { it.exists() }?.delete()
remoteSideContext.log.info("sif can't be loaded due to user preference")
return
}
val latestVersion = getVersion()?.trim() ?: run {
remoteSideContext.log.warn("Failed to get latest sif version")
return
}
if (currentVersion == latestVersion) {
remoteSideContext.log.info("sif is up to date ($currentVersion)")
return
}
remoteSideContext.log.info("Updating sif from $currentVersion to $latestVersion")
if (downloadLatest(libraryFile)) {
remoteSideContext.sharedPreferences.edit().putString("sif", latestVersion).commit()
remoteSideContext.shortToast("SIF updated to $latestVersion!")
// force restart snapchat
remoteSideContext.bridgeService?.stopSelf()
} else {
remoteSideContext.log.warn("Failed to download latest sif")
}
}
}

View File

@ -78,6 +78,7 @@ class RemoteSideContext(
val tracker = RemoteTracker(this) val tracker = RemoteTracker(this)
val accountStorage = RemoteAccountStorage(this) val accountStorage = RemoteAccountStorage(this)
val locationManager = RemoteLocationManager(this) val locationManager = RemoteLocationManager(this)
val remoteSharedLibraryManager = RemoteSharedLibraryManager(this)
//used to load bitmoji selfies and download previews //used to load bitmoji selfies and download previews
val imageLoader by lazy { val imageLoader by lazy {
@ -131,6 +132,9 @@ class RemoteSideContext(
messageLogger.purgeTrackerLogs(it) messageLogger.purgeTrackerLogs(it)
} }
} }
coroutineScope.launch {
remoteSharedLibraryManager.init()
}
} }
}.onFailure { }.onFailure {
log.error("Failed to load RemoteSideContext", it) log.error("Failed to load RemoteSideContext", it)
@ -212,6 +216,10 @@ class RemoteSideContext(
requirements = requirements or Requirements.MAPPINGS requirements = requirements or Requirements.MAPPINGS
} }
if (sharedPreferences.getString("sif", null) == null) {
requirements = requirements or Requirements.SIF
}
if (requirements == 0) return false if (requirements == 0) return false
val currentContext = activity ?: androidContext val currentContext = activity ?: androidContext

View File

@ -3,6 +3,7 @@ package me.rhunk.snapenhance.action
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material.icons.filled.PersonSearch
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
@ -21,4 +22,7 @@ enum class EnumQuickActions(
LOGGER_HISTORY("logger_history", Icons.Default.History, { LOGGER_HISTORY("logger_history", Icons.Default.History, {
loggerHistory.navigateReset() loggerHistory.navigateReset()
}), }),
THEMING("theming", Icons.Default.Palette, {
theming.navigateReset()
})
} }

View File

@ -89,6 +89,19 @@ class AppDatabase(
"longitude DOUBLE", "longitude DOUBLE",
"radius DOUBLE", "radius DOUBLE",
), ),
"themes" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"enabled BOOLEAN DEFAULT 0",
"name VARCHAR",
"description TEXT",
"version VARCHAR",
"author VARCHAR",
"updateUrl VARCHAR",
"content TEXT",
),
"repositories" to listOf(
"url VARCHAR PRIMARY KEY",
),
)) ))
} }
} }

View File

@ -0,0 +1,34 @@
package me.rhunk.snapenhance.storage
import android.content.ContentValues
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getRepositories(): List<String> {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT url FROM repositories", null).use { cursor ->
val repos = mutableListOf<String>()
while (cursor.moveToNext()) {
repos.add(cursor.getStringOrNull("url") ?: continue)
}
repos
}
}
}
fun AppDatabase.removeRepo(url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
database.delete("repositories", "url = ?", arrayOf(url))
}
}
fun AppDatabase.addRepo(url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
database.insert("repositories", null, ContentValues().apply {
put("url", url)
})
}
}

View File

@ -0,0 +1,126 @@
package me.rhunk.snapenhance.storage
import android.content.ContentValues
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.data.DatabaseTheme
import me.rhunk.snapenhance.common.data.DatabaseThemeContent
import me.rhunk.snapenhance.common.util.ktx.getIntOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getThemeList(): List<DatabaseTheme> {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT * FROM themes ORDER BY id DESC", null).use { cursor ->
val themes = mutableListOf<DatabaseTheme>()
while (cursor.moveToNext()) {
themes.add(
DatabaseTheme(
id = cursor.getIntOrNull("id") ?: continue,
enabled = cursor.getIntOrNull("enabled") == 1,
name = cursor.getStringOrNull("name") ?: continue,
description = cursor.getStringOrNull("description"),
version = cursor.getStringOrNull("version"),
author = cursor.getStringOrNull("author"),
updateUrl = cursor.getStringOrNull("updateUrl")
)
)
}
themes
}
}
}
fun AppDatabase.getThemeInfo(id: Int): DatabaseTheme? {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT * FROM themes WHERE id = ?", arrayOf(id.toString())).use { cursor ->
if (!cursor.moveToFirst()) return@use null
DatabaseTheme(
id = cursor.getIntOrNull("id") ?: return@use null,
enabled = cursor.getIntOrNull("enabled") == 1,
name = cursor.getStringOrNull("name") ?: return@use null,
description = cursor.getStringOrNull("description"),
version = cursor.getStringOrNull("version"),
author = cursor.getStringOrNull("author"),
updateUrl = cursor.getStringOrNull("updateUrl")
)
}
}
}
fun AppDatabase.getThemeIdByUpdateUrl(updateUrl: String): Int? {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT id FROM themes WHERE updateUrl = ?", arrayOf(updateUrl)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getIntOrNull("id")
}
}
}
fun AppDatabase.addOrUpdateTheme(theme: DatabaseTheme, themeId: Int? = null): Int {
return runBlocking(executor.asCoroutineDispatcher()) {
val contentValues = ContentValues().apply {
put("enabled", if (theme.enabled) 1 else 0)
put("name", theme.name)
put("description", theme.description)
put("version", theme.version)
put("author", theme.author)
put("updateUrl", theme.updateUrl)
}
if (themeId != null) {
database.update("themes", contentValues, "id = ?", arrayOf(themeId.toString()))
return@runBlocking themeId
}
database.insert("themes", null, contentValues).toInt()
}
}
fun AppDatabase.setThemeState(id: Int, enabled: Boolean) {
runBlocking(executor.asCoroutineDispatcher()) {
database.update("themes", ContentValues().apply {
put("enabled", if (enabled) 1 else 0)
}, "id = ?", arrayOf(id.toString()))
}
}
fun AppDatabase.deleteTheme(id: Int) {
runBlocking(executor.asCoroutineDispatcher()) {
database.delete("themes", "id = ?", arrayOf(id.toString()))
}
}
fun AppDatabase.getThemeContent(id: Int): DatabaseThemeContent? {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT content FROM themes WHERE id = ?", arrayOf(id.toString())).use { cursor ->
if (!cursor.moveToFirst()) return@use null
runCatching {
context.gson.fromJson(cursor.getStringOrNull("content"), DatabaseThemeContent::class.java)
}.getOrNull()
}
}
}
fun AppDatabase.getEnabledThemesContent(): List<DatabaseThemeContent> {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT content FROM themes WHERE enabled = 1", null).use { cursor ->
val themes = mutableListOf<DatabaseThemeContent>()
while (cursor.moveToNext()) {
runCatching {
themes.add(context.gson.fromJson(cursor.getStringOrNull("content"), DatabaseThemeContent::class.java))
}
}
themes
}
}
}
fun AppDatabase.setThemeContent(id: Int, content: DatabaseThemeContent) {
runBlocking(executor.asCoroutineDispatcher()) {
database.update("themes", ContentValues().apply {
put("content", context.gson.toJson(content))
}, "id = ?", arrayOf(id.toString()))
}
}

View File

@ -24,6 +24,9 @@ import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview 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.social.SocialRootSection
import me.rhunk.snapenhance.ui.manager.pages.theming.EditThemeSection
import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection
import me.rhunk.snapenhance.ui.manager.pages.theming.ThemingRoot
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
@ -58,6 +61,10 @@ class Routes(
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule()) val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home) val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
val theming = route(RouteInfo("theming"), ThemingRoot()).parent(home)
val editTheme = route(RouteInfo("edit_theme/?theme_id={theme_id}"), EditThemeSection())
val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection()) val 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 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) 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.MediaDownloadSource
import me.rhunk.snapenhance.common.data.download.createNewFilePath import me.rhunk.snapenhance.common.data.download.createNewFilePath
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState 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.copyToClipboard
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
@ -373,14 +374,7 @@ class LoggerHistoryRoot : Routes.Route() {
.padding(end = 10.dp) .padding(end = 10.dp)
.height(70.dp), .height(70.dp),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = transparentTextFieldColors()
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary
)
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View File

@ -0,0 +1,187 @@
package me.rhunk.snapenhance.ui.manager.pages
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.RepositoryIndex
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
import me.rhunk.snapenhance.storage.addRepo
import me.rhunk.snapenhance.storage.getRepositories
import me.rhunk.snapenhance.storage.removeRepo
import me.rhunk.snapenhance.ui.manager.Routes
import okhttp3.OkHttpClient
class ManageReposSection: Routes.Route() {
private val updateDispatcher = AsyncUpdateDispatcher()
private val okHttpClient by lazy { OkHttpClient() }
override val floatingActionButton: @Composable () -> Unit = {
var showAddDialog by remember { mutableStateOf(false) }
ExtendedFloatingActionButton(onClick = {
showAddDialog = true
}) {
Text("Add Repository")
}
if (showAddDialog) {
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
suspend fun addRepo(url: String) {
var modifiedUrl = url;
if (url.startsWith("https://github.com/")) {
val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/")
val repoName = splitUrl[0] + "/" + splitUrl[1]
// fetch default branch
okHttpClient.newCall(
okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build()
).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Failed to fetch default branch: ${response.code}")
}
val json = response.body.string()
val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String
context.log.info("Default branch for $repoName is $defaultBranch")
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
}
}
val indexUri = modifiedUrl.toUri().buildUpon().appendPath("index.json").build()
okHttpClient.newCall(
okhttp3.Request.Builder().url(indexUri.toString()).build()
).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Failed to fetch index from $indexUri: ${response.code}")
}
runCatching {
val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also {
context.log.info("repository index: $it")
}
context.database.addRepo(modifiedUrl)
context.shortToast("Repository added successfully! $repoIndex")
showAddDialog = false
updateDispatcher.dispatch()
}.onFailure {
throw Exception("Failed to parse index from $indexUri")
}
}
}
var url by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
AlertDialog(onDismissRequest = {
showAddDialog = false
}, title = {
Text("Add Repository URL")
}, text = {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
},
value = url,
onValueChange = {
url = it
}, label = {
Text("Repository URL")
}
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
}
}, confirmButton = {
Button(
enabled = !loading,
onClick = {
loading = true;
coroutineScope.launch {
runCatching {
addRepo(url)
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast("Failed to add repository: ${it.message}")
}
loading = false
}
}
) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Add")
}
}
})
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
context.database.getRepositories()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
) {
item {
if (repositories.isEmpty()) {
Text("No repositories added", modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
}
}
items(repositories) { url ->
ElevatedCard(onClick = {}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(Icons.Default.Public, contentDescription = null)
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1)
Button(
onClick = {
context.database.removeRepo(url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
}
) {
Text("Remove")
}
}
}
}
}
}
}

View File

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

View File

@ -154,6 +154,9 @@ class HomeSettings : Routes.Route() {
RowAction(key = "change_language") { RowAction(key = "change_language") {
context.checkForRequirements(Requirements.LANGUAGE) context.checkForRequirements(Requirements.LANGUAGE)
} }
RowAction(key = "security_features") {
context.checkForRequirements(Requirements.SIF)
}
RowTitle(title = translation["message_logger_title"]) RowTitle(title = translation["message_logger_title"])
ShiftedRow { ShiftedRow {
Column( Column(
@ -284,7 +287,7 @@ class HomeSettings : Routes.Route() {
) { ) {
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading") PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading")
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper") PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper")
PreferenceToggle(context.sharedPreferences, key = "force_native_load", text = "Force Native Load") PreferenceToggle(context.sharedPreferences, key = "disable_sif", text = "Disable Security Features")
} }
} }
Spacer(modifier = Modifier.height(50.dp)) Spacer(modifier = Modifier.height(50.dp))

View File

@ -22,11 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
@ -34,6 +30,7 @@ import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
import me.rhunk.snapenhance.storage.isScriptEnabled import me.rhunk.snapenhance.storage.isScriptEnabled
import me.rhunk.snapenhance.storage.setScriptEnabled import me.rhunk.snapenhance.storage.setScriptEnabled
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
@ -101,6 +98,11 @@ class ScriptingRootSection : Routes.Route() {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
) )
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( Button(
enabled = url.isNotBlank(), enabled = url.isNotBlank(),

View File

@ -0,0 +1,393 @@
package me.rhunk.snapenhance.ui.manager.pages.theming
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.data.*
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.CircularAlphaTile
import me.rhunk.snapenhance.ui.util.Dialog
class EditThemeSection: Routes.Route() {
private var saveCallback by mutableStateOf<(() -> Unit)?>(null)
private var addEntryCallback by mutableStateOf<(key: String, initialColor: Int) -> Unit>({ _, _ -> })
private var deleteCallback by mutableStateOf<(() -> Unit)?>(null)
private var themeColors = mutableStateListOf<ThemeColorEntry>()
private val alertDialogs by lazy {
AlertDialogs(context.translation)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var deleteConfirmationDialog by remember { mutableStateOf(false) }
if (deleteConfirmationDialog) {
Dialog(onDismissRequest = {
deleteConfirmationDialog = false
}) {
alertDialogs.ConfirmDialog(
title = "Delete Theme",
message = "Are you sure you want to delete this theme?",
onConfirm = {
deleteCallback?.invoke()
deleteConfirmationDialog = false
},
onDismiss = {
deleteConfirmationDialog = false
}
)
}
}
deleteCallback?.let {
IconButton(onClick = {
deleteConfirmationDialog = true
}) {
Icon(Icons.Default.Delete, contentDescription = null)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
override val floatingActionButton: @Composable () -> Unit = {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
var addAttributeDialog by remember { mutableStateOf(false) }
val attributesTranslation = remember { context.translation.getCategory("theming_attributes") }
if (addAttributeDialog) {
AlertDialog(
title = { Text("Select an attribute to add") },
onDismissRequest = {
addAttributeDialog = false
},
confirmButton = {},
text = {
var filter by remember { mutableStateOf("") }
val attributes = rememberAsyncMutableStateList(defaultValue = listOf(), keys = arrayOf(filter)) {
AvailableThemingAttributes[ThemingAttributeType.COLOR]?.filter { key ->
themeColors.none { it.key == key } && (key.contains(filter, ignoreCase = true) || attributesTranslation.getOrNull(key)?.contains(filter, ignoreCase = true) == true)
} ?: emptyList()
}
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.7f)
.fillMaxWidth(),
) {
stickyHeader {
TextField(
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
value = filter,
onValueChange = { filter = it },
label = { Text("Search") },
colors = transparentTextFieldColors().copy(
focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright
)
)
}
item {
if (attributes.isEmpty()) {
Text("No attributes")
}
}
items(attributes) { attribute ->
Card(
modifier = Modifier.padding(5.dp).fillMaxWidth(),
onClick = {
addEntryCallback(attribute, Color.White.toArgb())
addAttributeDialog = false
}
) {
val attributeTranslation = remember(attribute) {
attributesTranslation.getOrNull(attribute)
}
Column(
modifier = Modifier.padding(8.dp)
) {
Text(attributeTranslation ?: attribute, lineHeight = 15.sp)
attributeTranslation?.let {
Text(attribute, fontWeight = FontWeight.Light, fontSize = 10.sp, lineHeight = 15.sp)
}
}
}
}
}
}
)
}
FloatingActionButton(onClick = {
addAttributeDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = null)
}
saveCallback?.let {
FloatingActionButton(onClick = {
it()
}) {
Icon(Icons.Default.Save, contentDescription = null)
}
}
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val currentThemeId = remember { it.arguments?.getString("theme_id")?.toIntOrNull() }
LaunchedEffect(Unit) {
themeColors.clear()
}
var themeName by remember { mutableStateOf("") }
var themeDescription by remember { mutableStateOf("") }
var themeVersion by remember { mutableStateOf("1.0.0") }
var themeAuthor by remember { mutableStateOf("") }
var themeUpdateUrl by remember { mutableStateOf("") }
val themeInfo by rememberAsyncMutableState(defaultValue = null) {
currentThemeId?.let { themeId ->
context.database.getThemeInfo(themeId)?.also { theme ->
themeName = theme.name
themeDescription = theme.description ?: ""
theme.version?.let { themeVersion = it }
themeAuthor = theme.author ?: ""
themeUpdateUrl = theme.updateUrl ?: ""
}
}
}
val lazyListState = rememberLazyListState()
rememberAsyncMutableState(defaultValue = DatabaseThemeContent(), keys = arrayOf(themeInfo)) {
currentThemeId?.let {
context.database.getThemeContent(it)?.also { content ->
themeColors.clear()
themeColors.addAll(content.colors)
withContext(Dispatchers.Main) {
lazyListState.scrollToItem(themeColors.size)
}
}
} ?: DatabaseThemeContent()
}
if (themeName.isNotBlank()) {
saveCallback = {
coroutineScope.launch(Dispatchers.IO) {
val theme = DatabaseTheme(
id = currentThemeId ?: -1,
enabled = themeInfo?.enabled ?: false,
name = themeName,
description = themeDescription,
version = themeVersion,
author = themeAuthor,
updateUrl = themeUpdateUrl
)
val themeId = context.database.addOrUpdateTheme(theme, currentThemeId)
context.database.setThemeContent(themeId, DatabaseThemeContent(
colors = themeColors
))
withContext(Dispatchers.Main) {
routes.theming.navigateReload()
}
}
}
} else {
saveCallback = null
}
LaunchedEffect(Unit) {
deleteCallback = null
if (currentThemeId != null) {
deleteCallback = {
coroutineScope.launch(Dispatchers.IO) {
context.database.deleteTheme(currentThemeId)
withContext(Dispatchers.Main) {
routes.theming.navigateReload()
}
}
}
}
addEntryCallback = { key, initialColor ->
coroutineScope.launch(Dispatchers.Main) {
themeColors.add(ThemeColorEntry(key, initialColor))
delay(100)
lazyListState.scrollToItem(themeColors.size)
}
}
}
var moreOptionsExpanded by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
val focusRequester = remember { FocusRequester() }
TextField(
modifier = Modifier.weight(1f).focusRequester(focusRequester),
value = themeName,
onValueChange = { themeName = it },
label = { Text("Theme Name") },
colors = transparentTextFieldColors(),
singleLine = true,
)
LaunchedEffect(Unit) {
if (currentThemeId == null) {
delay(200)
focusRequester.requestFocus()
}
}
IconButton(
modifier = Modifier.padding(4.dp),
onClick = {
moreOptionsExpanded = !moreOptionsExpanded
}
) {
Icon(if (moreOptionsExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = null)
}
}
if (moreOptionsExpanded) {
TextField(
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
value = themeDescription,
onValueChange = { themeDescription = it },
label = { Text("Description") },
colors = transparentTextFieldColors()
)
TextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
value = themeVersion,
onValueChange = { themeVersion = it },
label = { Text("Version") },
colors = transparentTextFieldColors()
)
TextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
value = themeAuthor,
onValueChange = { themeAuthor = it },
label = { Text("Author") },
colors = transparentTextFieldColors()
)
TextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
value = themeUpdateUrl,
onValueChange = { themeUpdateUrl = it },
label = { Text("Update URL") },
colors = transparentTextFieldColors()
)
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = lazyListState,
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
reverseLayout = true,
) {
item {
Spacer(modifier = Modifier.height(150.dp))
}
items(themeColors) { colorEntry ->
var showEditColorDialog by remember { mutableStateOf(false) }
var currentColor by remember { mutableIntStateOf(colorEntry.value) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth(),
onClick = {
showEditColorDialog = true
}
) {
Row(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Colorize, contentDescription = null, modifier = Modifier.padding(8.dp))
Column(
modifier = Modifier.weight(1f)
) {
val translation = remember(colorEntry.key) { context.translation.getOrNull("theming_attributes.${colorEntry.key}") }
Text(text = translation ?: colorEntry.key, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 15.sp)
translation?.let {
Text(text = colorEntry.key, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 15.sp)
}
}
CircularAlphaTile(selectedColor = Color(currentColor))
}
}
if (showEditColorDialog) {
Dialog(onDismissRequest = { showEditColorDialog = false }) {
alertDialogs.ColorPickerDialog(
initialColor = Color(currentColor),
setProperty = {
if (it == null) {
themeColors.remove(colorEntry)
return@ColorPickerDialog
}
currentColor = it.toArgb()
colorEntry.value = currentColor
},
dismiss = {
showEditColorDialog = false
}
)
}
}
}
item {
if (themeColors.isEmpty()) {
Text("No colors added yet", modifier = Modifier
.fillMaxWidth()
.padding(8.dp), fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
}
}
}
}
}
}

View File

@ -0,0 +1,272 @@
package me.rhunk.snapenhance.ui.manager.pages.theming
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.data.RepositoryIndex
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.storage.getThemeList
import me.rhunk.snapenhance.storage.getRepositories
import me.rhunk.snapenhance.storage.getThemeIdByUpdateUrl
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
import okhttp3.Request
private val cachedRepoIndexes = mutableStateMapOf<String, RepositoryIndex>()
private val cacheReloadDispatcher = AsyncUpdateDispatcher()
@Composable
fun ThemeCatalog(root: ThemingRoot) {
val context = remember { root.context }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
fun fetchRepoIndexes(): Map<String, RepositoryIndex>? {
val indexes = mutableMapOf<String, RepositoryIndex>()
context.database.getRepositories().forEach { rootUri ->
val indexUri = rootUri.toUri().buildUpon().appendPath("index.json").build()
runCatching {
root.okHttpClient.newCall(
Request.Builder().url(indexUri.toString()).build()
).execute().use { response ->
if (!response.isSuccessful) {
context.log.error("Failed to fetch theme index from $indexUri: ${response.code}")
context.shortToast("Failed to fetch index of $indexUri")
return@forEach
}
runCatching {
indexes[rootUri] = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java)
}.onFailure {
context.log.error("Failed to parse theme index from $indexUri", it)
context.shortToast("Failed to parse index of $indexUri")
}
}
}.onFailure {
context.log.error("Failed to fetch theme index from $indexUri", it)
context.shortToast("Failed to fetch index of $indexUri")
}
}
return indexes
}
suspend fun installTheme(themeUri: Uri) {
root.okHttpClient.newCall(
Request.Builder().url(themeUri.toString()).build()
).execute().use { response ->
if (!response.isSuccessful) {
context.log.error("Failed to fetch theme from $themeUri: ${response.code}")
context.shortToast("Failed to fetch theme from $themeUri")
return
}
val themeContent = response.body.bytes().toString(Charsets.UTF_8)
root.importTheme(themeContent, themeUri.toString())
}
}
var isRefreshing by remember { mutableStateOf(false) }
suspend fun refreshCachedIndexes() {
isRefreshing = true
coroutineScope {
launch(Dispatchers.IO) {
fetchRepoIndexes()?.let {
context.log.verbose("Fetched ${it.size} theme indexes")
it.forEach { (t, u) ->
context.log.verbose("Fetched theme index from $t with ${u.themes.size} themes")
}
synchronized(cachedRepoIndexes) {
cachedRepoIndexes.clear()
cachedRepoIndexes += it
}
cacheReloadDispatcher.dispatch()
delay(600)
isRefreshing = false
}
}
}
}
val installedThemes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = root.localReloadDispatcher, keys = arrayOf(cachedRepoIndexes)) {
context.database.getThemeList()
}
val remoteThemes by rememberAsyncMutableState(defaultValue = listOf(), updateDispatcher = cacheReloadDispatcher, keys = arrayOf(root.searchFilter.value)) {
cachedRepoIndexes.entries.flatMap {
it.value.themes.map { theme -> it.key to theme }
}.let {
val filter = root.searchFilter.value
if (filter.isNotBlank()) {
it.filter { (_, theme) ->
theme.name.contains(filter, ignoreCase = true) || theme.description?.contains(filter, ignoreCase = true) == true
}
} else it
}
}
LaunchedEffect(Unit) {
if (cachedRepoIndexes.isNotEmpty()) return@LaunchedEffect
isRefreshing = true
coroutineScope.launch {
refreshCachedIndexes()
}
}
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
coroutineScope.launch {
refreshCachedIndexes()
}
})
Box(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
contentPadding = PaddingValues(8.dp)
) {
item {
if (remoteThemes.isEmpty()) {
Text(
text = "No themes available",
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 15.sp,
fontWeight = FontWeight.Light
)
}
}
items(remoteThemes, key = { it.first + it.second.hashCode() }) { (_, themeManifest) ->
val themeUri = remember {
cachedRepoIndexes.entries.find { it.value.themes.contains(themeManifest) }?.key?.toUri()?.buildUpon()?.appendPath(themeManifest.filepath)?.build()
}
val hasUpdate by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) {
installedThemes.takeIf { themeUri != null }?.find { it.updateUrl == themeUri.toString() }?.let { installedTheme ->
installedTheme.version != themeManifest.version
} ?: false
}
var isInstalling by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(themeManifest)) {
false
}
var isInstalled by rememberAsyncMutableState(defaultValue = true, keys = arrayOf(themeManifest)) {
context.database.getThemeIdByUpdateUrl(themeUri.toString()) != null
}
ElevatedCard(onClick = {
//TODO: Show theme details
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
text = themeManifest.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
themeManifest.author?.let {
Text(
text = "by $it",
maxLines = 1,
fontSize = 10.sp,
fontWeight = FontWeight.Light,
overflow = TextOverflow.Ellipsis,
)
}
}
themeManifest.description?.let {
Text(
text = it,
fontSize = 12.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
if (hasUpdate) {
Text(
text = "Version ${themeManifest.version} available",
fontWeight = FontWeight.Bold
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (isInstalling) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
Button(
enabled = !isInstalled || hasUpdate,
onClick = {
isInstalling = true
context.coroutineScope.launch {
runCatching {
installTheme(themeUri ?: throw IllegalStateException("Failed to get theme URI"))
isInstalled = true
}.onFailure {
context.log.error("Failed to install theme ${themeManifest.name}", it)
context.shortToast("Failed to install theme ${themeManifest.name}. ${it.message}")
}
isInstalling = false
}
}
) {
if (hasUpdate) {
Text("Update")
} else {
Text(if (isInstalled) "Installed" else "Install")
}
}
}
}
}
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}

View File

@ -0,0 +1,463 @@
package me.rhunk.snapenhance.ui.manager.pages.theming
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.data.DatabaseTheme
import me.rhunk.snapenhance.common.data.DatabaseThemeContent
import me.rhunk.snapenhance.common.data.ExportedTheme
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.openFile
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
import me.rhunk.snapenhance.ui.util.saveFile
import okhttp3.OkHttpClient
class ThemingRoot: Routes.Route() {
val localReloadDispatcher = AsyncUpdateDispatcher()
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private var currentPage by mutableIntStateOf(0)
val okHttpClient by lazy { OkHttpClient() }
val searchFilter = mutableStateOf("")
private fun exportTheme(theme: DatabaseTheme) {
context.coroutineScope.launch {
val exportedTheme = theme.toExportedTheme(context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
activityLauncherHelper.saveFile(theme.name.replace(" ", "_").lowercase() + ".json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
outputStream.write(context.gson.toJson(exportedTheme).toByteArray())
outputStream.flush()
}
context.shortToast("Theme exported successfully")
}.onFailure {
context.log.error("Failed to save theme", it)
context.longToast("Failed to export theme! Check logs for more details")
}
}
}
}
private fun duplicateTheme(theme: DatabaseTheme) {
context.coroutineScope.launch {
val themeId = context.database.addOrUpdateTheme(theme.copy(
updateUrl = null
))
context.database.setThemeContent(themeId, context.database.getThemeContent(theme.id) ?: DatabaseThemeContent())
context.shortToast("Theme duplicated successfully")
withContext(Dispatchers.Main) {
localReloadDispatcher.dispatch()
}
}
}
suspend fun importTheme(content: String, updateUrl: String? = null) {
val theme = context.gson.fromJson(content, ExportedTheme::class.java)
val existingTheme = updateUrl?.let {
context.database.getThemeIdByUpdateUrl(it)
}?.let {
context.database.getThemeInfo(it)
}
val databaseTheme = theme.toDatabaseTheme(
updateUrl = updateUrl,
enabled = existingTheme?.enabled ?: false
)
val themeId = context.database.addOrUpdateTheme(
themeId = existingTheme?.id,
theme = databaseTheme
)
context.database.setThemeContent(themeId, theme.content)
context.shortToast("Theme imported successfully")
withContext(Dispatchers.Main) {
localReloadDispatcher.dispatch()
}
}
private fun importTheme() {
activityLauncherHelper.openFile { uri ->
context.coroutineScope.launch {
runCatching {
val themeJson = context.androidContext.contentResolver.openInputStream(uri.toUri())?.bufferedReader().use {
it?.readText()
} ?: throw Exception("Failed to read file")
importTheme(themeJson)
}.onFailure {
context.log.error("Failed to import theme", it)
context.longToast("Failed to import theme! Check logs for more details")
}
}
}
}
private suspend fun importFromURL(url: String) {
val result = okHttpClient.newCall(
okhttp3.Request.Builder()
.url(url)
.build()
).execute()
if (!result.isSuccessful) {
throw Exception("Failed to fetch theme from URL ${result.message}")
}
importTheme(result.body.string(), url)
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showSearchBar by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (showSearchBar) {
OutlinedTextField(
value = searchFilter.value,
onValueChange = { searchFilter.value = it },
placeholder = { Text("Search") },
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
},
colors = transparentTextFieldColors()
)
DisposableEffect(Unit) {
onDispose {
searchFilter.value = ""
}
}
}
IconButton(onClick = {
showSearchBar = !showSearchBar
}) {
Icon(if (showSearchBar) Icons.Default.Close else Icons.Default.Search, contentDescription = null)
}
}
}
override val floatingActionButton: @Composable () -> Unit = {
var showImportFromUrlDialog by remember { mutableStateOf(false) }
if (showImportFromUrlDialog) {
var url by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showImportFromUrlDialog = false },
title = { Text("Import theme from URL") },
text = {
val focusRequester = remember { FocusRequester() }
TextField(
value = url,
onValueChange = { url = it },
label = { Text("URL") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
}
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
}
},
confirmButton = {
Button(
enabled = url.isNotBlank() && !loading,
onClick = {
loading = true
context.coroutineScope.launch {
runCatching {
importFromURL(url)
withContext(Dispatchers.Main) {
showImportFromUrlDialog = false
}
}.onFailure {
context.log.error("Failed to import theme", it)
context.longToast("Failed to import theme! ${it.message}")
}
withContext(Dispatchers.Main) {
loading = false
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Import")
}
}
)
}
Column(
horizontalAlignment = Alignment.End
) {
when (currentPage) {
0 -> {
ExtendedFloatingActionButton(
onClick = {
routes.editTheme.navigate()
},
icon = {
Icon(Icons.Default.Add, contentDescription = null)
},
text = {
Text("New theme")
}
)
Spacer(modifier = Modifier.height(8.dp))
ExtendedFloatingActionButton(
onClick = {
importTheme()
},
icon = {
Icon(Icons.Default.Upload, contentDescription = null)
},
text = {
Text("Import from file")
}
)
Spacer(modifier = Modifier.height(8.dp))
ExtendedFloatingActionButton(
onClick = { showImportFromUrlDialog = true },
icon = {
Icon(Icons.Default.Link, contentDescription = null)
},
text = {
Text("Import from URL")
}
)
}
1 -> {
ExtendedFloatingActionButton(
onClick = {
routes.manageRepos.navigate()
},
icon = {
Icon(Icons.Default.Public, contentDescription = null)
},
text = {
Text("Manage repositories")
}
)
}
}
}
}
@Composable
private fun InstalledThemes() {
val themes = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = localReloadDispatcher, keys = arrayOf(searchFilter.value)) {
context.database.getThemeList().let {
val filter = searchFilter.value
if (filter.isNotBlank()) {
it.filter { theme ->
theme.name.contains(filter, ignoreCase = true) ||
theme.author?.contains(filter, ignoreCase = true) == true ||
theme.description?.contains(filter, ignoreCase = true) == true
}
} else it
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
item {
if (themes.isEmpty()) {
Text(
text = translation["no_themes_hint"],
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 15.sp,
fontWeight = FontWeight.Light
)
}
}
items(themes, key = { it.id }) { theme ->
var showSettings by remember(theme) { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editTheme.navigate {
this["theme_id"] = theme.id.toString()
}
}
.padding(8.dp)
) {
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Palette, contentDescription = null, modifier = Modifier.padding(5.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(8.dp),
) {
Text(text = theme.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 20.sp)
theme.author?.takeIf { it.isNotBlank() }?.let {
Text(text = "by $it", lineHeight = 15.sp, fontWeight = FontWeight.Light, fontSize = 12.sp)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
var state by remember { mutableStateOf(theme.enabled) }
IconButton(onClick = {
showSettings = true
}) {
Icon(Icons.Default.Settings, contentDescription = null)
}
Switch(checked = state, onCheckedChange = {
state = it
context.database.setThemeState(theme.id, it)
})
}
}
}
if (showSettings) {
val actionsRow = remember {
mapOf(
("Duplicate" to Icons.Default.ContentCopy) to { duplicateTheme(theme) },
("Export" to Icons.Default.Download) to { exportTheme(theme) }
)
}
AlertDialog(
onDismissRequest = { showSettings = false },
title = { Text("Theme settings") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
actionsRow.forEach { entry ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showSettings = false
entry.value()
},
verticalAlignment = Alignment.CenterVertically
) {
Icon(entry.key.second, contentDescription = null, modifier = Modifier.padding(16.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(entry.key.first)
}
}
}
},
confirmButton = {}
)
}
}
item {
Spacer(modifier = Modifier.height(100.dp))
}
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val titles = remember { listOf("Installed Themes", "Catalog") }
val pagerState = rememberPagerState { titles.size }
currentPage = pagerState.currentPage
Column {
TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
Modifier.pagerTabIndicatorOffset(
pagerState = pagerState,
tabPositions = tabPositions
)
)
}) {
titles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
state = pagerState
) { page ->
when (page) {
0 -> InstalledThemes()
1 -> ThemeCatalog(this@ThemingRoot)
}
}
}
}
}

View File

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

View File

@ -1,21 +1,11 @@
package me.rhunk.snapenhance.ui.setup package me.rhunk.snapenhance.ui.setup
object Requirements { object Requirements {
const val FIRST_RUN = 0b00001 const val FIRST_RUN = 0b000001
const val LANGUAGE = 0b00010 const val LANGUAGE = 0b000010
const val MAPPINGS = 0b00100 const val MAPPINGS = 0b000100
const val SAVE_FOLDER = 0b01000 const val SAVE_FOLDER = 0b001000
const val GRANT_PERMISSIONS = 0b10000 const val GRANT_PERMISSIONS = 0b010000
const val SIF = 0b100000
fun getName(requirement: Int): String {
return when (requirement) {
FIRST_RUN -> "FIRST_RUN"
LANGUAGE -> "LANGUAGE"
MAPPINGS -> "MAPPINGS"
SAVE_FOLDER -> "SAVE_FOLDER"
GRANT_PERMISSIONS -> "GRANT_PERMISSIONS"
else -> "UNKNOWN"
}
}
} }

View File

@ -29,10 +29,7 @@ import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.common.ui.AppMaterialTheme import me.rhunk.snapenhance.common.ui.AppMaterialTheme
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen import me.rhunk.snapenhance.ui.setup.screens.impl.*
import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen
class SetupActivity : ComponentActivity() { class SetupActivity : ComponentActivity() {
@ -69,6 +66,9 @@ class SetupActivity : ComponentActivity() {
if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) { if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) {
add(MappingsScreen().apply { route = "mappings" }) add(MappingsScreen().apply { route = "mappings" })
} }
if (isFirstRun || hasRequirement(Requirements.SIF)) {
add(SecurityScreen().apply { route = "security" })
}
} }
// If there are no required screens, we can just finish the activity // If there are no required screens, we can just finish the activity

View File

@ -0,0 +1,86 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
class SecurityScreen : SetupScreen() {
@Composable
override fun Content() {
Icon(
imageVector = Icons.Default.WarningAmber,
contentDescription = null,
modifier = Modifier.padding(16.dp).size(30.dp),
)
DialogText(
"Since Snapchat has implemented additional security measures against third-party applications such as SnapEnhance, we offer a non-opensource solution that reduces the risk of banning and prevents Snapchat from detecting SnapEnhance. " +
"\nPlease note that this solution does not provide a ban bypass or spoofer for anything, and does not take any personal data or communicate with the network." +
"\nWe also encourage you to use official signed builds to avoid compromising the security of your account." +
"\nIf you're having trouble using the solution, or are experiencing crashes, join the Telegram Group for help: https://t.me/snapenhance_chat"
)
var denyDialog by remember { mutableStateOf(false) }
if (denyDialog) {
AlertDialog(
onDismissRequest = {
denyDialog = false
},
text = {
Text("Are you sure you don't want to use this solution? You can always change this later in the settings in the SnapEnhance app.")
},
dismissButton = {
Button(onClick = {
denyDialog = false
}) {
Text("Go back")
}
},
confirmButton = {
Button(onClick = {
context.sharedPreferences.edit().putString("sif", "false").apply()
goNext()
}) {
Text("Yes, I'm sure")
}
}
)
}
Column (
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
context.coroutineScope.launch {
context.sharedPreferences.edit().putString("sif", "").commit()
context.remoteSharedLibraryManager.init()
}
goNext()
}
) {
Text("Accept and continue", fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
onClick = {
denyDialog = true
}
) {
Text("I don't want to use this solution")
}
}
}
}

View File

@ -360,12 +360,11 @@ class AlertDialogs(
@Composable @Composable
fun ColorPickerDialog( fun ColorPickerDialog(
property: PropertyPair<*>, initialColor: Color?,
dismiss: () -> Unit = {}, setProperty: (Color?) -> Unit,
dismiss: () -> Unit
) { ) {
var currentColor by remember { var currentColor by remember { mutableStateOf(initialColor) }
mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) })
}
DefaultDialogCard { DefaultDialogCard {
val controller = remember { ColorPickerController().apply { val controller = remember { ColorPickerController().apply {
@ -389,7 +388,7 @@ class AlertDialogs(
runCatching { runCatching {
currentColor = Color(android.graphics.Color.parseColor("#$value")).also { currentColor = Color(android.graphics.Color.parseColor("#$value")).also {
controller.selectByColor(it, true) controller.selectByColor(it, true)
property.value.setAny(it.toArgb()) setProperty(it)
} }
}.onFailure { }.onFailure {
currentColor = null currentColor = null
@ -417,7 +416,7 @@ class AlertDialogs(
if (!it.fromUser) return@HsvColorPicker if (!it.fromUser) return@HsvColorPicker
currentColor = it.color currentColor = it.color
colorHexValue = Integer.toHexString(it.color.toArgb()) colorHexValue = Integer.toHexString(it.color.toArgb())
property.value.setAny(it.color.toArgb()) setProperty(it.color)
} }
) )
AlphaSlider( AlphaSlider(
@ -450,7 +449,7 @@ class AlertDialogs(
controller = controller controller = controller
) )
IconButton(onClick = { IconButton(onClick = {
property.value.setAny(null) setProperty(null)
dismiss() dismiss()
}) { }) {
Icon( 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 @Composable
fun ChooseLocationDialog( fun ChooseLocationDialog(
property: PropertyPair<*>, property: PropertyPair<*>,

View File

@ -0,0 +1,27 @@
package me.rhunk.snapenhance.ui.util
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.skydoves.colorpicker.compose.AlphaTile
@Composable
fun CircularAlphaTile(
selectedColor: Color?,
) {
AlphaTile(
modifier = Modifier
.size(30.dp)
.border(2.dp, Color.White, shape = RoundedCornerShape(15.dp))
.clip(RoundedCornerShape(15.dp)),
selectedColor = selectedColor ?: Color.Transparent,
tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent,
tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent,
tileSize = 8.dp,
)
}

View File

@ -36,6 +36,9 @@
"friend_tracker": "Friend Tracker", "friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule", "edit_rule": "Edit Rule",
"file_imports": "File Imports", "file_imports": "File Imports",
"theming": "Theming",
"edit_theme": "Edit Theme",
"manage_repos": "Manage Repositories",
"social": "Social", "social": "Social",
"manage_scope": "Manage Scope", "manage_scope": "Manage Scope",
"messaging_preview": "Preview", "messaging_preview": "Preview",
@ -168,6 +171,9 @@
"search_bar": "Search", "search_bar": "Search",
"no_friends_map": "No friends on the map", "no_friends_map": "No friends on the map",
"no_friends_found": "No friends found" "no_friends_found": "No friends found"
},
"theming": {
"no_themes_hint": "No themes found"
} }
}, },
"dialogs": { "dialogs": {
@ -293,6 +299,10 @@
"name": "Change Language", "name": "Change Language",
"description": "Change the language of SnapEnhance" "description": "Change the language of SnapEnhance"
}, },
"security_features": {
"name": "Security Features",
"description": "Access security features"
},
"file_imports": { "file_imports": {
"name": "File Imports", "name": "File Imports",
"description": "Import files for use in Snapchat" "description": "Import files for use in Snapchat"
@ -304,6 +314,10 @@
"logger_history": { "logger_history": {
"name": "Logger History", "name": "Logger History",
"description": "View the history of logged messages" "description": "View the history of logged messages"
},
"theming": {
"name": "Theming",
"description": "Customize the look and feel of Snapchat"
} }
}, },
@ -415,65 +429,9 @@
"name": "Enable App Appearance Settings", "name": "Enable App Appearance Settings",
"description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions" "description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions"
}, },
"customize_ui": { "custom_theme": {
"name": "Colors", "name": "Custom Theme",
"description": "Customize Snapchats Colors", "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"
"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"
}
}
}
}
}, },
"friend_feed_message_preview": { "friend_feed_message_preview": {
"name": "Friend Feed Message Preview", "name": "Friend Feed Message Preview",
@ -1201,20 +1159,11 @@
"always_light": "Always Light", "always_light": "Always Light",
"always_dark": "Always Dark" "always_dark": "Always Dark"
}, },
"theme_picker": { "custom_theme": {
"amoled_dark_mode": "AMOLED Dark Mode", "amoled_dark_mode": "Amoled Dark Mode",
"custom": "Custom Colors", "custom": "Custom Themes (Use the Quick Actions to manage themes)",
"material_you_light": "Material You Light (Android 12+)", "material_you_light": "Material You Light (Android 12+)",
"material_you_dark": "Material You Dark (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"
}, },
"friend_feed_menu_buttons": { "friend_feed_menu_buttons": {
"auto_download": "\u2B07\uFE0F Auto Download", "auto_download": "\u2B07\uFE0F Auto Download",
@ -1681,5 +1630,48 @@
"date_input_invalid_year_range": "Invalid year", "date_input_invalid_year_range": "Invalid year",
"date_input_invalid_not_allowed": "Invalid date", "date_input_invalid_not_allowed": "Invalid date",
"date_range_input_invalid_range_input": "Invalid date range" "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"), INTERNAL("internal"),
LOCALE("locale"), LOCALE("locale"),
USER_IMPORT("user_import"), USER_IMPORT("user_import"),
COMPOSER("composer"); COMPOSER("composer"),
THEME("theme");
companion object { companion object {
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name } fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }
@ -31,8 +32,8 @@ enum class InternalFileHandleType(
CONFIG("config", "config.json"), CONFIG("config", "config.json"),
MAPPINGS("mappings", "mappings.json"), MAPPINGS("mappings", "mappings.json"),
MESSAGE_LOGGER("message_logger", "message_logger.db", isDatabase = true), MESSAGE_LOGGER("message_logger", "message_logger.db", isDatabase = true),
PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"); PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"),
SIF("sif", "libsif.so");
fun resolve(context: Context): File = if (isDatabase) { fun resolve(context: Context): File = if (isDatabase) {
context.getDatabasePath(fileName) context.getDatabasePath(fileName)

View File

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

View File

@ -31,7 +31,7 @@ class Experimental : ConfigContainer() {
val composerLogs = boolean("composer_logs") val composerLogs = boolean("composer_logs")
} }
class NativeHooks : ConfigContainer(hasGlobalState = true) { class NativeHooks : ConfigContainer() {
val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() } val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() }
val disableBitmoji = boolean("disable_bitmoji") val disableBitmoji = boolean("disable_bitmoji")
val customEmojiFont = string("custom_emoji_font") { val customEmojiFont = string("custom_emoji_font") {
@ -40,7 +40,6 @@ class Experimental : ConfigContainer() {
addFlags(ConfigFlag.USER_IMPORT) addFlags(ConfigFlag.USER_IMPORT)
filenameFilter = { it.endsWith(".ttf") } filenameFilter = { it.endsWith(".ttf") }
} }
val remapExecutable = boolean("remap_executable") { requireRestart(); addNotices(FeatureNotice.INTERNAL_BEHAVIOR, FeatureNotice.UNSTABLE) }
} }
class E2EEConfig : ConfigContainer(hasGlobalState = true) { class E2EEConfig : ConfigContainer(hasGlobalState = true) {

View File

@ -19,46 +19,18 @@ class UserInterfaceTweaks : ConfigContainer() {
} }
class ColorsConfig : ConfigContainer() {
val textColor = color("text_color")
val chatChatTextColor = color("chat_chat_text_color")
val pendingSendingTextColor = color("pending_sending_text_color")
val snapWithSoundTextColor = color("snap_with_sound_text_color")
val snapWithoutSoundTextColor = color("snap_without_sound_text_color")
val backgroundColor = color("background_color")
val backgroundColorSurface = color("background_color_surface")
val friendFeedConversationsLineColor = color("friend_feed_conversations_line_color")
val actionMenuBackgroundColor = color("action_menu_background_color")
val actionMenuRoundBackgroundColor = color("action_menu_round_background_color")
val cameraGridLines = color("camera_grid_lines")
}
inner class CustomizeUIConfig : ConfigContainer() {
val themePicker = unique("theme_picker",
"custom",
"amoled_dark_mode",
"material_you_light",
"material_you_dark",
"light_blue",
"dark_blue",
"earthy_autumn",
"mint_chocolate",
"ginger_snap",
"lemon_meringue",
"lava_flow",
"ocean_fog",
"alien_landscape",
)
val colors = container("colors", ColorsConfig()) { requireRestart() }
}
val friendFeedMenuButtons = multiple( 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() "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 { ).apply {
set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key))
} }
val autoCloseFriendFeedMenu = boolean("auto_close_friend_feed_menu") 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 friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() }
val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() }

View File

@ -0,0 +1,120 @@
package me.rhunk.snapenhance.common.data
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class ThemeColorEntry(
@SerializedName("key")
val key: String,
@SerializedName("value")
var value: Int,
): Parcelable
@Parcelize
data class DatabaseThemeContent(
@SerializedName("colors")
val colors: List<ThemeColorEntry> = emptyList(),
): Parcelable
data class DatabaseTheme(
val id: Int,
val enabled: Boolean,
val name: String,
val description: String?,
val version: String?,
val author: String?,
val updateUrl: String?,
) {
fun toExportedTheme(content: DatabaseThemeContent): ExportedTheme {
return ExportedTheme(
name = name,
description = description,
version = version,
author = author,
content = content,
)
}
}
data class ExportedTheme(
val name: String,
val description: String?,
val version: String?,
val author: String?,
val content: DatabaseThemeContent,
) {
fun toDatabaseTheme(id: Int = -1, updateUrl: String? = null, enabled: Boolean = false): DatabaseTheme {
return DatabaseTheme(
id = id,
enabled = enabled,
name = name,
description = description,
version = version,
author = author,
updateUrl = updateUrl,
)
}
}
data class RepositoryThemeManifest(
val name: String,
val author: String?,
val description: String?,
val version: String?,
val filepath: String,
)
data class RepositoryIndex(
val themes: List<RepositoryThemeManifest> = emptyList(),
)
enum class ThemingAttributeType {
COLOR
}
val AvailableThemingAttributes = mapOf(
ThemingAttributeType.COLOR to listOf(
"sigColorTextPrimary",
"sigColorBackgroundSurface",
"sigColorBackgroundMain",
"actionSheetBackgroundDrawable",
"actionSheetRoundedBackgroundDrawable",
"sigColorChatChat",
"sigColorChatPendingSending",
"sigColorChatSnapWithSound",
"sigColorChatSnapWithoutSound",
"sigExceptionColorCameraGridLines",
"listDivider",
"listBackgroundDrawable",
"sigColorIconPrimary",
"actionSheetDescriptionTextColor",
"ringColor",
"sigColorIconSecondary",
"itemShapeFillColor",
"ringStartColor",
"sigColorLayoutPlaceholder",
"scButtonColor",
"recipientPillBackgroundDrawable",
"boxBackgroundColor",
"editTextColor",
"chipBackgroundColor",
"recipientInputStyle",
"rangeFillColor",
"pstsIndicatorColor",
"pstsTabBackground",
"pstsDividerColor",
"tabTextColor",
"statusBarForeground",
"statusBarBackground",
"strokeColor",
"storyReplayViewRingColor",
"sigColorButtonPrimary",
"sigColorBaseAppYellow",
"sigColorBackgroundSurfaceTranslucent",
"sigColorStoryRingFriendsFeedStoryRing",
"sigColorStoryRingDiscoverTabThumbnailStoryRing",
)
)

View File

@ -0,0 +1,17 @@
package me.rhunk.snapenhance.common.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable
fun transparentTextFieldColors() = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary
)

View File

@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
} }
fun Context.copyToClipboard(data: String, label: String = "Copied Text") { fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip( runCatching {
ClipData.newPlainText(label, data)) getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
ClipData.newPlainText(label, data))
}
}
fun Context.getTextFromClipboard(): String? {
return runCatching {
getSystemService(android.content.ClipboardManager::class.java).primaryClip
?.takeIf { it.itemCount > 0 }
?.getItemAt(0)
?.text?.toString()
}.getOrNull()
}
fun Context.getUrlFromClipboard(): String? {
return getTextFromClipboard()?.takeIf { it.startsWith("http") }
} }
fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor { fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor {

View File

@ -153,13 +153,11 @@ class ModContext(
} }
fun reloadNativeConfig() { fun reloadNativeConfig() {
if (config.experimental.nativeHooks.globalState != true) return
native.loadNativeConfig( native.loadNativeConfig(
NativeConfig( NativeConfig(
disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(),
disableMetrics = config.global.disableMetrics.get(), disableMetrics = config.global.disableMetrics.get(),
composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true, composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true,
remapExecutable = config.experimental.nativeHooks.remapExecutable.get(),
customEmojiFontPath = getCustomEmojiFontPath(this) customEmojiFontPath = getCustomEmojiFontPath(this)
) )
) )

View File

@ -4,7 +4,8 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import dalvik.system.BaseDexClassLoader import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -14,6 +15,9 @@ import me.rhunk.snapenhance.bridge.SyncCallback
import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.ReceiversConfig
import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.bridge.FileHandleScope
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
import me.rhunk.snapenhance.common.bridge.toWrapper
import me.rhunk.snapenhance.common.data.FriendStreaks import me.rhunk.snapenhance.common.data.FriendStreaks
import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo
@ -27,7 +31,6 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater
import me.rhunk.snapenhance.core.util.hook.HookAdapter import me.rhunk.snapenhance.core.util.hook.HookAdapter
import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@ -166,6 +169,8 @@ class SnapEnhance {
} }
} }
private var safeMode = false
private fun onActivityCreate(activity: Activity) { private fun onActivityCreate(activity: Activity) {
measureTimeMillis { measureTimeMillis {
with(appContext) { with(appContext) {
@ -173,6 +178,10 @@ class SnapEnhance {
inAppOverlay.onActivityCreate(activity) inAppOverlay.onActivityCreate(activity)
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) } scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) }
actionManager.onActivityCreate() actionManager.onActivityCreate()
if (safeMode) {
appContext.inAppOverlay.showStatusToast(Icons.Outlined.Cancel, "Failed to load security features! Snapchat may not work properly.", durationMs = 5000)
}
} }
}.also { time -> }.also { time ->
appContext.log.verbose("onActivityCreate took $time") appContext.log.verbose("onActivityCreate took $time")
@ -180,36 +189,58 @@ class SnapEnhance {
} }
private fun initNative() { private fun initNative() {
// don't initialize native when not logged in val lateInit = appContext.native.initOnce {
if ( nativeUnaryCallCallback = { request ->
!appContext.isLoggedIn() && appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
appContext.bridgeClient.getDebugProp("force_native_load", null) != "true" request.buffer = buffer
) return request.canceled = canceled
if (appContext.config.experimental.nativeHooks.globalState != true) return }
}
appContext.reloadNativeConfig()
}
if (appContext.bridgeClient.getDebugProp("disable_sif", "false") != "true") {
runCatching {
appContext.native.loadSharedLibrary(
appContext.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.SIF.key)
.toWrapper()
.readBytes()
.takeIf {
it.isNotEmpty()
} ?: throw IllegalStateException("buffer is empty")
)
appContext.log.verbose("loaded sif")
}.onFailure {
safeMode = true
appContext.log.error("Failed to load sif", it)
}
} else {
appContext.log.warn("sif is disabled")
}
lateinit var unhook: () -> Unit
Runtime::class.java.declaredMethods.first { Runtime::class.java.declaredMethods.first {
it.name == "loadLibrary0" && it.parameterTypes.contentEquals( it.name == "loadLibrary0" && it.parameterTypes.contentEquals(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Class::class.java, String::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Class::class.java, String::class.java)
else arrayOf(ClassLoader::class.java, String::class.java) else arrayOf(ClassLoader::class.java, String::class.java)
) )
}.hook(HookStage.AFTER) { param -> }.apply {
val libName = param.arg<String>(1) if (safeMode) {
if (libName != "client") return@hook hook(HookStage.BEFORE) { param ->
unhook() if (param.arg<String>(1) != "scplugin") return@hook
appContext.native.initOnce { appContext.log.warn("Can't load scplugin in safe mode")
nativeUnaryCallCallback = { request -> Thread.sleep(Long.MAX_VALUE)
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
request.buffer = buffer
request.canceled = canceled
}
} }
appContext.reloadNativeConfig()
} }
BaseDexClassLoader::class.java.hookConstructor(HookStage.AFTER) {
appContext.native.hideAnonymousDexFiles() lateinit var unhook: () -> Unit
} hook(HookStage.AFTER) { param ->
}.also { unhook = { it.unhook() } } val libName = param.arg<String>(1)
if (libName != "client") return@hook
unhook()
appContext.log.verbose("libclient lateInit")
lateInit()
}.also { unhook = { it.unhook() } }
}
} }
private fun initConfigListener() { private fun initConfigListener() {

View File

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

View File

@ -11,7 +11,7 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater
import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.hook.hook
class DeviceSpooferHook: Feature("device_spoofer") { class DeviceSpooferHook: Feature("Device Spoofer") {
private fun hookInstallerPackageName() { private fun hookInstallerPackageName() {
context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param -> context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param ->
param.setResult("com.android.vending") param.setResult("com.android.vending")

View File

@ -0,0 +1,130 @@
package me.rhunk.snapenhance.core.features.impl.ui
import android.content.res.TypedArray
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import android.util.TypedValue
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.ui.graphics.toArgb
import com.google.gson.reflect.TypeToken
import me.rhunk.snapenhance.common.bridge.FileHandleScope
import me.rhunk.snapenhance.common.data.DatabaseThemeContent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
import me.rhunk.snapenhance.core.util.ktx.getObjectField
class CustomTheming: Feature("Custom Theming") {
private fun getAttribute(name: String): Int {
return context.resources.getIdentifier(name, "attr")
}
private fun parseAttributeList(vararg attributes: Pair<String, Number>): Map<Int, Int> {
return attributes.toMap().mapKeys {
getAttribute(it.key)
}.filterKeys { it != 0 }.mapValues {
it.value.toInt()
}
}
override fun init() {
val customThemeName = context.config.userInterface.customTheme.getNullable() ?: return
var currentTheme = mapOf<Int, Int>() // resource id -> color
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val colorScheme = dynamicDarkColorScheme(context.androidContext)
val light = customThemeName == "material_you_light"
val surfaceVariant = (if (light) colorScheme.surfaceVariant else colorScheme.onSurfaceVariant).toArgb()
val background = (if (light) colorScheme.onBackground else colorScheme.background).toArgb()
currentTheme = parseAttributeList(
"sigColorTextPrimary" to surfaceVariant,
"sigColorChatChat" to surfaceVariant,
"sigColorChatPendingSending" to surfaceVariant,
"sigColorChatSnapWithSound" to surfaceVariant,
"sigColorChatSnapWithoutSound" to surfaceVariant,
"sigColorBackgroundMain" to background,
"sigColorBackgroundSurface" to background,
"listDivider" to colorScheme.onPrimary.copy(alpha = 0.12f).toArgb(),
"actionSheetBackgroundDrawable" to background,
"actionSheetRoundedBackgroundDrawable" to background,
"sigExceptionColorCameraGridLines" to background,
)
}
if (customThemeName == "amoled_dark_mode") {
currentTheme = parseAttributeList(
"sigColorTextPrimary" to 0xFFFFFFFF,
"sigColorChatChat" to 0xFFFFFFFF,
"sigColorChatPendingSending" to 0xFFFFFFFF,
"sigColorChatSnapWithSound" to 0xFFFFFFFF,
"sigColorChatSnapWithoutSound" to 0xFFFFFFFF,
"sigColorBackgroundMain" to 0xFF000000,
"sigColorBackgroundSurface" to 0xFF000000,
"listDivider" to 0xFF000000,
"actionSheetBackgroundDrawable" to 0xFF000000,
"actionSheetRoundedBackgroundDrawable" to 0xFF000000,
"sigExceptionColorCameraGridLines" to 0xFF000000,
)
}
if (customThemeName == "custom") {
val availableThemes = context.fileHandlerManager.getFileHandle(FileHandleScope.THEME.key, "")?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
AutoCloseInputStream(pfd).use { it.readBytes() }
}?.let {
context.gson.fromJson(it.toString(Charsets.UTF_8), object: TypeToken<List<DatabaseThemeContent>>() {})
} ?: run {
context.log.verbose("no custom themes found")
return
}
val customThemeColors = mutableMapOf<Int, Int>()
context.log.verbose("loading ${availableThemes.size} custom themes")
availableThemes.forEach { themeContent ->
themeContent.colors.forEach colors@{ colorEntry ->
customThemeColors[getAttribute(colorEntry.key).takeIf { it != 0 }.also {
if (it == null) {
context.log.warn("unknown color attribute: ${colorEntry.key}")
}
} ?: return@colors] = colorEntry.value
}
}
currentTheme = customThemeColors
context.log.verbose("loaded ${customThemeColors.size} custom theme colors")
}
onNextActivityCreate {
if (currentTheme.isEmpty()) return@onNextActivityCreate
context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook(
HookStage.AFTER) { param ->
val array = param.arg<IntArray>(0)
val customColor = (currentTheme[array[0]] as? Number)?.toInt() ?: return@hook
val result = param.getResult() as TypedArray
val typedArrayData = result.getObjectField("mData") as IntArray
when (val attributeType = result.getType(0)) {
TypedValue.TYPE_INT_COLOR_ARGB8, TypedValue.TYPE_INT_COLOR_RGB8, TypedValue.TYPE_INT_COLOR_ARGB4, TypedValue.TYPE_INT_COLOR_RGB4 -> {
typedArrayData[1] = customColor // index + STYLE_DATA
}
TypedValue.TYPE_STRING -> {
val stringValue = result.getString(0)
if (stringValue?.endsWith(".xml") == true) {
typedArrayData[0] = TypedValue.TYPE_INT_COLOR_ARGB4 // STYLE_TYPE
typedArrayData[1] = customColor // STYLE_DATA
typedArrayData[5] = 0; // STYLE_DENSITY
}
}
else -> context.log.warn("unknown attribute type: ${attributeType.toString(16)}")
}
}
}
}
}

View File

@ -1,211 +0,0 @@
package me.rhunk.snapenhance.core.features.impl.ui
import android.content.res.TypedArray
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.util.TypedValue
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.ui.graphics.toArgb
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
class CustomizeUI: Feature("Customize UI") {
private fun getAttribute(name: String): Int {
return context.resources.getIdentifier(name, "attr")
}
override fun init() {
val customizeUIConfig = context.config.userInterface.customizeUi
val themePicker = customizeUIConfig.themePicker.getNullable() ?: return
val colorsConfig = context.config.userInterface.customizeUi.colors
if (themePicker == "custom") {
themes.clear()
themes[themePicker] = mapOf(
"sigColorTextPrimary" to colorsConfig.textColor.getNullable(),
"sigColorChatChat" to colorsConfig.chatChatTextColor.getNullable(),
"sigColorChatPendingSending" to colorsConfig.pendingSendingTextColor.getNullable(),
"sigColorChatSnapWithSound" to colorsConfig.snapWithSoundTextColor.getNullable(),
"sigColorChatSnapWithoutSound" to colorsConfig.snapWithoutSoundTextColor.getNullable(),
"sigColorBackgroundMain" to colorsConfig.backgroundColor.getNullable(),
"listDivider" to colorsConfig.friendFeedConversationsLineColor.getNullable(),
"sigColorBackgroundSurface" to colorsConfig.backgroundColorSurface.getNullable(),
"actionSheetBackgroundDrawable" to colorsConfig.actionMenuBackgroundColor.getNullable(),
"actionSheetRoundedBackgroundDrawable" to colorsConfig.actionMenuRoundBackgroundColor.getNullable(),
"sigExceptionColorCameraGridLines" to colorsConfig.cameraGridLines.getNullable(),
).filterValues { it != null }.map { (key, value) ->
getAttribute(key) to value!!
}.toMap()
}
if (themePicker == "material_you_light" || themePicker == "material_you_dark") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val colorScheme = dynamicDarkColorScheme(context.androidContext)
val light = themePicker == "material_you_light"
themes.clear()
val surfaceVariant = (if (light) colorScheme.surfaceVariant else colorScheme.onSurfaceVariant).toArgb()
val background = (if (light) colorScheme.onBackground else colorScheme.background).toArgb()
themes[themePicker] = mapOf(
"sigColorTextPrimary" to surfaceVariant,
"sigColorChatChat" to surfaceVariant,
"sigColorChatPendingSending" to surfaceVariant,
"sigColorChatSnapWithSound" to surfaceVariant,
"sigColorChatSnapWithoutSound" to surfaceVariant,
"sigColorBackgroundMain" to background,
"sigColorBackgroundSurface" to background,
"listDivider" to colorScheme.onPrimary.copy(alpha = 0.12f).toArgb(),
"actionSheetBackgroundDrawable" to background,
"actionSheetRoundedBackgroundDrawable" to background,
"sigExceptionColorCameraGridLines" to background,
).map { getAttribute(it.key) to it.value }.toMap()
}
}
context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook(
HookStage.AFTER) { param ->
val array = param.arg<IntArray>(0)
val result = param.getResult() as TypedArray
fun ephemeralHook(methodName: String, content: Any) {
Hooker.ephemeralHookObjectMethod(result::class.java, result, methodName, HookStage.BEFORE) {
it.setResult(content)
}
}
themes[themePicker]?.get(array[0])?.let { value ->
when (val attributeType = result.getType(0)) {
TypedValue.TYPE_INT_COLOR_ARGB8, TypedValue.TYPE_INT_COLOR_RGB8, TypedValue.TYPE_INT_COLOR_ARGB4, TypedValue.TYPE_INT_COLOR_RGB4 -> {
ephemeralHook("getColor", (value as Number).toInt())
}
TypedValue.TYPE_STRING -> {
val stringValue = result.getString(0)
if (stringValue?.endsWith(".xml") == true) {
ephemeralHook("getDrawable", ColorDrawable((value as Number).toInt()))
}
}
else -> context.log.warn("unknown attribute type: ${attributeType.toString(16)}")
}
}
}
}
private val themes by lazy {
mapOf(
"amoled_dark_mode" to mapOf(
"sigColorTextPrimary" to 0xFFFFFFFF,
"sigColorBackgroundMain" to 0xFF000000,
"sigColorBackgroundSurface" to 0xFF000000,
"listDivider" to 0xFF000000,
"actionSheetBackgroundDrawable" to 0xFF000000,
"actionSheetRoundedBackgroundDrawable" to 0xFF000000
),
"light_blue" to mapOf(
"sigColorTextPrimary" to 0xFF03BAFC,
"sigColorBackgroundMain" to 0xFFBDE6FF,
"sigColorBackgroundSurface" to 0xFF78DBFF,
"listDivider" to 0xFFBDE6FF,
"actionSheetBackgroundDrawable" to 0xFF78DBFF,
"sigColorChatChat" to 0xFF08D6FF,
"sigColorChatPendingSending" to 0xFF08D6FF,
"sigColorChatSnapWithSound" to 0xFF08D6FF,
"sigColorChatSnapWithoutSound" to 0xFF08D6FF,
"sigExceptionColorCameraGridLines" to 0xFF08D6FF
),
"dark_blue" to mapOf(
"sigColorTextPrimary" to 0xFF98C2FD,
"sigColorBackgroundMain" to 0xFF192744,
"sigColorBackgroundSurface" to 0xFF192744,
"actionSheetBackgroundDrawable" to 0xFF192744,
"sigColorChatChat" to 0xFF98C2FD,
"sigColorChatPendingSending" to 0xFF98C2FD,
"sigColorChatSnapWithSound" to 0xFF98C2FD,
"sigColorChatSnapWithoutSound" to 0xFF98C2FD,
"sigExceptionColorCameraGridLines" to 0xFF192744
),
"earthy_autumn" to mapOf(
"sigColorTextPrimary" to 0xFFF7CAC9,
"sigColorBackgroundMain" to 0xFF800000,
"sigColorBackgroundSurface" to 0xFF800000,
"actionSheetBackgroundDrawable" to 0xFF800000,
"sigColorChatChat" to 0xFFF7CAC9,
"sigColorChatPendingSending" to 0xFFF7CAC9,
"sigColorChatSnapWithSound" to 0xFFF7CAC9,
"sigColorChatSnapWithoutSound" to 0xFFF7CAC9,
"sigExceptionColorCameraGridLines" to 0xFF800000
),
"mint_chocolate" to mapOf(
"sigColorTextPrimary" to 0xFFFFFFFF,
"sigColorBackgroundMain" to 0xFF98FF98,
"sigColorBackgroundSurface" to 0xFF98FF98,
"actionSheetBackgroundDrawable" to 0xFF98FF98,
"sigColorChatChat" to 0xFFFFFFFF,
"sigColorChatPendingSending" to 0xFFFFFFFF,
"sigColorChatSnapWithSound" to 0xFFFFFFFF,
"sigColorChatSnapWithoutSound" to 0xFFFFFFFF,
"sigExceptionColorCameraGridLines" to 0xFF98FF98
),
"ginger_snap" to mapOf(
"sigColorTextPrimary" to 0xFFFFFFFF,
"sigColorBackgroundMain" to 0xFFC6893A,
"sigColorBackgroundSurface" to 0xFFC6893A,
"actionSheetBackgroundDrawable" to 0xFFC6893A,
"sigColorChatChat" to 0xFFFFFFFF,
"sigColorChatPendingSending" to 0xFFFFFFFF,
"sigColorChatSnapWithSound" to 0xFFFFFFFF,
"sigColorChatSnapWithoutSound" to 0xFFFFFFFF,
"sigExceptionColorCameraGridLines" to 0xFFC6893A
),
"lemon_meringue" to mapOf(
"sigColorTextPrimary" to 0xFF000000,
"sigColorBackgroundMain" to 0xFFFCFFE7,
"sigColorBackgroundSurface" to 0xFFFCFFE7,
"actionSheetBackgroundDrawable" to 0xFFFCFFE7,
"sigColorChatChat" to 0xFF000000,
"sigColorChatPendingSending" to 0xFF000000,
"sigColorChatSnapWithSound" to 0xFF000000,
"sigColorChatSnapWithoutSound" to 0xFF000000,
"sigExceptionColorCameraGridLines" to 0xFFFCFFE7
),
"lava_flow" to mapOf(
"sigColorTextPrimary" to 0xFFFFCC00,
"sigColorBackgroundMain" to 0xFFC70039,
"sigColorBackgroundSurface" to 0xFFC70039,
"actionSheetBackgroundDrawable" to 0xFFC70039,
"sigColorChatChat" to 0xFFFFCC00,
"sigColorChatPendingSending" to 0xFFFFCC00,
"sigColorChatSnapWithSound" to 0xFFFFCC00,
"sigColorChatSnapWithoutSound" to 0xFFFFCC00,
"sigExceptionColorCameraGridLines" to 0xFFC70039
),
"ocean_fog" to mapOf(
"sigColorTextPrimary" to 0xFF333333,
"sigColorBackgroundMain" to 0xFFB0C4DE,
"sigColorBackgroundSurface" to 0xFFB0C4DE,
"actionSheetBackgroundDrawable" to 0xFFB0C4DE,
"sigColorChatChat" to 0xFF333333,
"sigColorChatPendingSending" to 0xFF333333,
"sigColorChatSnapWithSound" to 0xFF333333,
"sigColorChatSnapWithoutSound" to 0xFF333333,
"sigExceptionColorCameraGridLines" to 0xFFB0C4DE
),
"alien_landscape" to mapOf(
"sigColorTextPrimary" to 0xFFFFFFFF,
"sigColorBackgroundMain" to 0xFF9B59B6,
"sigColorBackgroundSurface" to 0xFF9B59B6,
"actionSheetBackgroundDrawable" to 0xFF9B59B6,
"sigColorChatChat" to 0xFFFFFFFF,
"sigColorChatPendingSending" to 0xFFFFFFFF,
"sigColorChatSnapWithSound" to 0xFFFFFFFF,
"sigColorChatSnapWithoutSound" to 0xFFFFFFFF,
"sigExceptionColorCameraGridLines" to 0xFF9B59B6
)
).mapValues { (_, attributes) ->
attributes.map { (key, value) ->
getAttribute(key) to value as Any
}.toMap()
}.toMutableMap()
}
}

View File

@ -13,7 +13,6 @@ typedef struct {
bool disable_bitmoji; bool disable_bitmoji;
bool disable_metrics; bool disable_metrics;
bool composer_hooks; bool composer_hooks;
bool remap_executable;
char custom_emoji_font_path[256]; char custom_emoji_font_path[256];
} native_config_t; } native_config_t;

View File

@ -9,8 +9,5 @@ static pthread_mutex_t hook_mutex = PTHREAD_MUTEX_INITIALIZER;
static void inline SafeHook(void *addr, void *hook, void **original) { static void inline SafeHook(void *addr, void *hook, void **original) {
pthread_mutex_lock(&hook_mutex); pthread_mutex_lock(&hook_mutex);
DobbyHook(addr, hook, original); DobbyHook(addr, hook, original);
if (common::native_config->remap_executable) {
mprotect((void *)((uintptr_t) *original & PAGE_MASK), PAGE_SIZE, PROT_EXEC);
}
pthread_mutex_unlock(&hook_mutex); pthread_mutex_unlock(&hook_mutex);
} }

View File

@ -0,0 +1,54 @@
#pragma once
#include <map>
namespace LinkerHook {
static auto linker_openat_hooks = std::map<std::string, std::pair<uintptr_t, size_t>>();
void JNICALL addLinkerSharedLibrary(JNIEnv *env, jobject, jstring path, jbyteArray content) {
const char *path_str = env->GetStringUTFChars(path, nullptr);
jsize content_len = env->GetArrayLength(content);
jbyte *content_ptr = env->GetByteArrayElements(content, nullptr);
auto allocated_content = (jbyte *) malloc(content_len);
memcpy(allocated_content, content_ptr, content_len);
linker_openat_hooks[path_str] = std::make_pair((uintptr_t) allocated_content, content_len);
LOGD("added linker hook for %s, size=%d", path_str, content_len);
env->ReleaseStringUTFChars(path, path_str);
env->ReleaseByteArrayElements(content, content_ptr, JNI_ABORT);
}
HOOK_DEF(int, linker_openat, int dirfd, const char *pathname, int flags, mode_t mode) {
for (const auto &item: linker_openat_hooks) {
if (strstr(pathname, item.first.c_str())) {
LOGD("found openat hook for %s", pathname);
static auto memfd_create = (int (*)(const char *, unsigned int)) DobbySymbolResolver("libc.so", "memfd_create");
auto fd = memfd_create("me.rhunk.snapenhance", 0);
LOGD("memfd created: %d", fd);
if (fd == -1) {
LOGE("memfd_create failed: %d", errno);
return -1;
}
if (write(fd, (void *) item.second.first, item.second.second) == -1) {
LOGE("write failed: %d", errno);
return -1;
}
lseek(fd, 0, SEEK_SET);
free((void *) item.second.first);
linker_openat_hooks.erase(item.first);
LOGD("memfd written");
return fd;
}
}
return linker_openat_original(dirfd, pathname, flags, mode);
}
void init() {
DobbyHook((void *) DobbySymbolResolver(ARM64 ? "linker64" : "linker", "__dl___openat"), (void *) linker_openat, (void **) &linker_openat_original);
}
}

View File

@ -7,6 +7,7 @@
#include "logger.h" #include "logger.h"
#include "common.h" #include "common.h"
#include "dobby_helper.h" #include "dobby_helper.h"
#include "hooks/linker_hook.h"
#include "hooks/unary_call.h" #include "hooks/unary_call.h"
#include "hooks/fstat_hook.h" #include "hooks/fstat_hook.h"
#include "hooks/sqlite_mutex.h" #include "hooks/sqlite_mutex.h"
@ -17,9 +18,6 @@
bool JNICALL init(JNIEnv *env, jobject clazz) { bool JNICALL init(JNIEnv *env, jobject clazz) {
LOGD("Initializing native"); LOGD("Initializing native");
using namespace common; using namespace common;
util::remap_sections([](const std::string &line, size_t size) {
return line.find(BUILD_PACKAGE) != std::string::npos;
}, native_config->remap_executable);
native_lib_object = env->NewGlobalRef(clazz); native_lib_object = env->NewGlobalRef(clazz);
client_module = util::get_module("libclient.so"); client_module = util::get_module("libclient.so");
@ -66,7 +64,6 @@ void JNICALL load_config(JNIEnv *env, jobject, jobject config_object) {
native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji"); native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji");
native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics"); native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics");
native_config->composer_hooks = GET_CONFIG_BOOL("composerHooks"); native_config->composer_hooks = GET_CONFIG_BOOL("composerHooks");
native_config->remap_executable = GET_CONFIG_BOOL("remapExecutable");
memset(native_config->custom_emoji_font_path, 0, sizeof(native_config->custom_emoji_font_path)); memset(native_config->custom_emoji_font_path, 0, sizeof(native_config->custom_emoji_font_path));
auto custom_emoji_font_path = env->GetObjectField(config_object, env->GetFieldID(native_config_clazz, "customEmojiFontPath", "Ljava/lang/String;")); auto custom_emoji_font_path = env->GetObjectField(config_object, env->GetFieldID(native_config_clazz, "customEmojiFontPath", "Ljava/lang/String;"));
@ -97,15 +94,6 @@ void JNICALL lock_database(JNIEnv *env, jobject, jstring database_name, jobject
} }
} }
void JNICALL hide_anonymous_dex_files(JNIEnv *, jobject) {
util::remap_sections([](const std::string &line, size_t size) {
return (
(common::native_config->remap_executable && size == PAGE_SIZE && line.find("r-xp 00000000 00") != std::string::npos && line.find("[v") == std::string::npos) ||
line.find("dalvik-DEX") != std::string::npos ||
line.find("dalvik-classes") != std::string::npos
);
}, common::native_config->remap_executable);
}
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) {
common::java_vm = vm; common::java_vm = vm;
@ -118,7 +106,9 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) {
methods.push_back({"lockDatabase", "(Ljava/lang/String;Ljava/lang/Runnable;)V", (void *)lock_database}); methods.push_back({"lockDatabase", "(Ljava/lang/String;Ljava/lang/Runnable;)V", (void *)lock_database});
methods.push_back({"setComposerLoader", "(Ljava/lang/String;)V", (void *) ComposerHook::setComposerLoader}); methods.push_back({"setComposerLoader", "(Ljava/lang/String;)V", (void *) ComposerHook::setComposerLoader});
methods.push_back({"composerEval", "(Ljava/lang/String;)Ljava/lang/String;",(void *) ComposerHook::composerEval}); methods.push_back({"composerEval", "(Ljava/lang/String;)Ljava/lang/String;",(void *) ComposerHook::composerEval});
methods.push_back({"hideAnonymousDexFiles", "()V", (void *)hide_anonymous_dex_files}); methods.push_back({"addLinkerSharedLibrary", "(Ljava/lang/String;[B)V", (void *) LinkerHook::addLinkerSharedLibrary});
LinkerHook::init();
env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size()); env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size());
return JNI_VERSION_1_6; return JNI_VERSION_1_6;

View File

@ -52,46 +52,6 @@ namespace util {
return { start_offset, end_offset - start_offset }; return { start_offset, end_offset - start_offset };
} }
static void remap_sections(std::function<bool(const std::string &, size_t)> filter, bool remove_read_permission) {
char buff[256];
auto maps = fopen("/proc/self/maps", "rt");
while (fgets(buff, sizeof buff, maps) != NULL) {
int len = strlen(buff);
if (len > 0 && buff[len - 1] == '\n') buff[--len] = '\0';
size_t start, end, offset;
char flags[4];
if (sscanf(buff, "%zx-%zx %c%c%c%c %zx", &start, &end,
&flags[0], &flags[1], &flags[2], &flags[3], &offset) != 7) continue;
if (!filter(buff, end - start)) continue;
auto section_size = end - start;
auto section_ptr = mmap(0, section_size, PROT_READ | PROT_EXEC | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (section_ptr == MAP_FAILED) {
LOGE("mmap failed: %s", strerror(errno));
break;
}
memcpy(section_ptr, (void *)start, section_size);
if (mremap(section_ptr, section_size, section_size, MREMAP_MAYMOVE | MREMAP_FIXED, start) == MAP_FAILED) {
LOGE("mremap failed: %s", strerror(errno));
break;
}
auto new_prot = (flags[0] == 'r' ? PROT_READ : 0) | (flags[1] == 'w' ? PROT_WRITE : 0) | (flags[2] == 'x' ? PROT_EXEC : 0);
if (remove_read_permission && flags[0] == 'r' && flags[2] == 'x') {
new_prot &= ~PROT_READ;
}
mprotect((void *)start, section_size, new_prot);
}
fclose(maps);
}
static uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) { static uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) {
std::vector<char> bytes; std::vector<char> bytes;
std::vector<char> mask; std::vector<char> mask;

View File

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

View File

@ -1,6 +1,9 @@
package me.rhunk.snapenhance.nativelib package me.rhunk.snapenhance.nativelib
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import kotlin.math.absoluteValue
import kotlin.random.Random
@Suppress("KotlinJniMissingFunction") @Suppress("KotlinJniMissingFunction")
class NativeLib { class NativeLib {
@ -11,19 +14,21 @@ class NativeLib {
private set private set
} }
fun initOnce(callback: NativeLib.() -> Unit) { fun initOnce(callback: NativeLib.() -> Unit): () -> Unit {
if (initialized) throw IllegalStateException("NativeLib already initialized") if (initialized) throw IllegalStateException("NativeLib already initialized")
runCatching { return runCatching {
System.loadLibrary(BuildConfig.NATIVE_NAME) System.loadLibrary(BuildConfig.NATIVE_NAME)
initialized = true initialized = true
callback(this) callback(this)
if (!init()) { return@runCatching {
throw IllegalStateException("NativeLib init failed. Check logcat for more info") if (!init()) {
throw IllegalStateException("NativeLib init failed. Check logcat for more info")
}
} }
}.onFailure { }.onFailure {
initialized = false initialized = false
Log.e("SnapEnhance", "NativeLib init failed", it) Log.e("SnapEnhance", "NativeLib init failed", it)
} }.getOrThrow()
} }
@Suppress("unused") @Suppress("unused")
@ -54,10 +59,18 @@ class NativeLib {
} }
} }
@SuppressLint("UnsafeDynamicallyLoadedCode")
fun loadSharedLibrary(content: ByteArray) {
if (!initialized) throw IllegalStateException("NativeLib not initialized")
val generatedPath = "/data/app/${Random.nextLong().absoluteValue.toString(16)}.so"
addLinkerSharedLibrary(generatedPath, content)
System.load(generatedPath)
}
private external fun init(): Boolean private external fun init(): Boolean
private external fun loadConfig(config: NativeConfig) private external fun loadConfig(config: NativeConfig)
private external fun lockDatabase(name: String, callback: Runnable) private external fun lockDatabase(name: String, callback: Runnable)
external fun setComposerLoader(code: String) external fun setComposerLoader(code: String)
external fun composerEval(code: String): String? external fun composerEval(code: String): String?
external fun hideAnonymousDexFiles() private external fun addLinkerSharedLibrary(path: String, content: ByteArray)
} }