mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-29 13:00:17 +02:00
Merge branch 'refs/heads/refactor_ex' into refactor
This commit is contained in:
commit
12ad30ffd8
@ -8,10 +8,28 @@ import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
||||
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
||||
import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
|
||||
import me.rhunk.snapenhance.storage.getEnabledThemesContent
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
|
||||
class ByteArrayFileHandle(
|
||||
private val context: RemoteSideContext,
|
||||
private val data: ByteArray
|
||||
): FileHandle.Stub() {
|
||||
override fun exists() = true
|
||||
override fun create() = false
|
||||
override fun delete() = false
|
||||
|
||||
override fun open(mode: Int): ParcelFileDescriptor? {
|
||||
return runCatching {
|
||||
data.inputStream().toParcelFileDescriptor(context.coroutineScope)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open byte array file handle: ${it.message}", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
class LocalFileHandle(
|
||||
private val file: File
|
||||
): FileHandle.Stub() {
|
||||
@ -97,6 +115,12 @@ class RemoteFileHandleManager(
|
||||
"composer/${name.substringAfterLast("/")}"
|
||||
)
|
||||
}
|
||||
FileHandleScope.THEME -> {
|
||||
return ByteArrayFileHandle(
|
||||
context,
|
||||
context.gson.toJson(context.database.getEnabledThemesContent()).toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -78,6 +78,7 @@ class RemoteSideContext(
|
||||
val tracker = RemoteTracker(this)
|
||||
val accountStorage = RemoteAccountStorage(this)
|
||||
val locationManager = RemoteLocationManager(this)
|
||||
val remoteSharedLibraryManager = RemoteSharedLibraryManager(this)
|
||||
|
||||
//used to load bitmoji selfies and download previews
|
||||
val imageLoader by lazy {
|
||||
@ -131,6 +132,9 @@ class RemoteSideContext(
|
||||
messageLogger.purgeTrackerLogs(it)
|
||||
}
|
||||
}
|
||||
coroutineScope.launch {
|
||||
remoteSharedLibraryManager.init()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
log.error("Failed to load RemoteSideContext", it)
|
||||
@ -212,6 +216,10 @@ class RemoteSideContext(
|
||||
requirements = requirements or Requirements.MAPPINGS
|
||||
}
|
||||
|
||||
if (sharedPreferences.getString("sif", null) == null) {
|
||||
requirements = requirements or Requirements.SIF
|
||||
}
|
||||
|
||||
if (requirements == 0) return false
|
||||
|
||||
val currentContext = activity ?: androidContext
|
||||
|
@ -3,6 +3,7 @@ package me.rhunk.snapenhance.action
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PersonSearch
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
@ -21,4 +22,7 @@ enum class EnumQuickActions(
|
||||
LOGGER_HISTORY("logger_history", Icons.Default.History, {
|
||||
loggerHistory.navigateReset()
|
||||
}),
|
||||
THEMING("theming", Icons.Default.Palette, {
|
||||
theming.navigateReset()
|
||||
})
|
||||
}
|
@ -89,6 +89,19 @@ class AppDatabase(
|
||||
"longitude DOUBLE",
|
||||
"radius DOUBLE",
|
||||
),
|
||||
"themes" to listOf(
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
"enabled BOOLEAN DEFAULT 0",
|
||||
"name VARCHAR",
|
||||
"description TEXT",
|
||||
"version VARCHAR",
|
||||
"author VARCHAR",
|
||||
"updateUrl VARCHAR",
|
||||
"content TEXT",
|
||||
),
|
||||
"repositories" to listOf(
|
||||
"url VARCHAR PRIMARY KEY",
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
126
app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt
Normal file
126
app/src/main/kotlin/me/rhunk/snapenhance/storage/Theming.kt
Normal 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()))
|
||||
}
|
||||
}
|
@ -24,6 +24,9 @@ import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
|
||||
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.theming.EditThemeSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection
|
||||
import me.rhunk.snapenhance.ui.manager.pages.theming.ThemingRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
|
||||
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
|
||||
@ -58,6 +61,10 @@ class Routes(
|
||||
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
|
||||
|
||||
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
|
||||
val theming = route(RouteInfo("theming"), ThemingRoot()).parent(home)
|
||||
val editTheme = route(RouteInfo("edit_theme/?theme_id={theme_id}"), EditThemeSection())
|
||||
val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection())
|
||||
|
||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection())
|
||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
||||
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)
|
||||
|
@ -38,6 +38,7 @@ import me.rhunk.snapenhance.common.data.download.DownloadRequest
|
||||
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
||||
import me.rhunk.snapenhance.common.data.download.createNewFilePath
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
|
||||
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||
@ -373,14 +374,7 @@ class LoggerHistoryRoot : Routes.Route() {
|
||||
.padding(end = 10.dp)
|
||||
.height(70.dp),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
colors = transparentTextFieldColors()
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -40,6 +40,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.config.*
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||
import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
|
||||
import me.rhunk.snapenhance.ui.manager.MainActivity
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.*
|
||||
@ -177,11 +178,15 @@ class FeaturesRootSection : Routes.Route() {
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().padding(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp),
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
@ -200,10 +205,13 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
items(files, key = { it.name }) { file ->
|
||||
Row(
|
||||
modifier = Modifier.clickable {
|
||||
selectedFile = if (selectedFile == file.name) null else file.name
|
||||
propertyValue.setAny(selectedFile)
|
||||
}.padding(5.dp),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
selectedFile =
|
||||
if (selectedFile == file.name) null else file.name
|
||||
propertyValue.setAny(selectedFile)
|
||||
}
|
||||
.padding(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp))
|
||||
@ -321,23 +329,13 @@ class FeaturesRootSection : Routes.Route() {
|
||||
|
||||
DataProcessors.Type.INT_COLOR -> {
|
||||
dialogComposable = {
|
||||
alertDialogs.ColorPickerDialog(property) {
|
||||
alertDialogs.ColorPickerPropertyDialog(property) {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
|
||||
val selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }
|
||||
AlphaTile(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.border(2.dp, Color.White, shape = RoundedCornerShape(15.dp))
|
||||
.clip(RoundedCornerShape(15.dp)),
|
||||
selectedColor = selectedColor ?: Color.Transparent,
|
||||
tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent,
|
||||
tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent,
|
||||
tileSize = 8.dp,
|
||||
)
|
||||
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
|
||||
}
|
||||
}
|
||||
|
||||
@ -489,14 +487,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
.padding(end = 10.dp)
|
||||
.height(70.dp),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
colors = transparentTextFieldColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -154,6 +154,9 @@ class HomeSettings : Routes.Route() {
|
||||
RowAction(key = "change_language") {
|
||||
context.checkForRequirements(Requirements.LANGUAGE)
|
||||
}
|
||||
RowAction(key = "security_features") {
|
||||
context.checkForRequirements(Requirements.SIF)
|
||||
}
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
ShiftedRow {
|
||||
Column(
|
||||
@ -284,7 +287,7 @@ class HomeSettings : Routes.Route() {
|
||||
) {
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading")
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper")
|
||||
PreferenceToggle(context.sharedPreferences, key = "force_native_load", text = "Force Native Load")
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_sif", text = "Disable Security Features")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
@ -22,11 +22,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
||||
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
|
||||
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
||||
@ -34,6 +30,7 @@ import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
|
||||
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
|
||||
import me.rhunk.snapenhance.storage.isScriptEnabled
|
||||
import me.rhunk.snapenhance.storage.setScriptEnabled
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
@ -101,6 +98,11 @@ class ScriptingRootSection : Routes.Route() {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
context.androidContext.getUrlFromClipboard()?.let {
|
||||
url = it
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
enabled = url.isNotBlank(),
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -74,9 +74,12 @@ fun LogsTab(
|
||||
|
||||
suspend fun loadNewLogs() {
|
||||
withContext(Dispatchers.IO) {
|
||||
logs.addAll(getPaginatedLogs(pageIndex).apply {
|
||||
pageIndex += 1
|
||||
})
|
||||
getPaginatedLogs(pageIndex).let {
|
||||
withContext(Dispatchers.Main) {
|
||||
logs.addAll(it)
|
||||
pageIndex += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,21 +1,11 @@
|
||||
package me.rhunk.snapenhance.ui.setup
|
||||
|
||||
object Requirements {
|
||||
const val FIRST_RUN = 0b00001
|
||||
const val LANGUAGE = 0b00010
|
||||
const val MAPPINGS = 0b00100
|
||||
const val SAVE_FOLDER = 0b01000
|
||||
const val GRANT_PERMISSIONS = 0b10000
|
||||
|
||||
fun getName(requirement: Int): String {
|
||||
return when (requirement) {
|
||||
FIRST_RUN -> "FIRST_RUN"
|
||||
LANGUAGE -> "LANGUAGE"
|
||||
MAPPINGS -> "MAPPINGS"
|
||||
SAVE_FOLDER -> "SAVE_FOLDER"
|
||||
GRANT_PERMISSIONS -> "GRANT_PERMISSIONS"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
}
|
||||
const val FIRST_RUN = 0b000001
|
||||
const val LANGUAGE = 0b000010
|
||||
const val MAPPINGS = 0b000100
|
||||
const val SAVE_FOLDER = 0b001000
|
||||
const val GRANT_PERMISSIONS = 0b010000
|
||||
const val SIF = 0b100000
|
||||
}
|
||||
|
||||
|
@ -29,10 +29,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.rhunk.snapenhance.common.ui.AppMaterialTheme
|
||||
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
|
||||
import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen
|
||||
import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen
|
||||
import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen
|
||||
import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen
|
||||
import me.rhunk.snapenhance.ui.setup.screens.impl.*
|
||||
|
||||
|
||||
class SetupActivity : ComponentActivity() {
|
||||
@ -69,6 +66,9 @@ class SetupActivity : ComponentActivity() {
|
||||
if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) {
|
||||
add(MappingsScreen().apply { route = "mappings" })
|
||||
}
|
||||
if (isFirstRun || hasRequirement(Requirements.SIF)) {
|
||||
add(SecurityScreen().apply { route = "security" })
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no required screens, we can just finish the activity
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -360,12 +360,11 @@ class AlertDialogs(
|
||||
|
||||
@Composable
|
||||
fun ColorPickerDialog(
|
||||
property: PropertyPair<*>,
|
||||
dismiss: () -> Unit = {},
|
||||
initialColor: Color?,
|
||||
setProperty: (Color?) -> Unit,
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var currentColor by remember {
|
||||
mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) })
|
||||
}
|
||||
var currentColor by remember { mutableStateOf(initialColor) }
|
||||
|
||||
DefaultDialogCard {
|
||||
val controller = remember { ColorPickerController().apply {
|
||||
@ -389,7 +388,7 @@ class AlertDialogs(
|
||||
runCatching {
|
||||
currentColor = Color(android.graphics.Color.parseColor("#$value")).also {
|
||||
controller.selectByColor(it, true)
|
||||
property.value.setAny(it.toArgb())
|
||||
setProperty(it)
|
||||
}
|
||||
}.onFailure {
|
||||
currentColor = null
|
||||
@ -417,7 +416,7 @@ class AlertDialogs(
|
||||
if (!it.fromUser) return@HsvColorPicker
|
||||
currentColor = it.color
|
||||
colorHexValue = Integer.toHexString(it.color.toArgb())
|
||||
property.value.setAny(it.color.toArgb())
|
||||
setProperty(it.color)
|
||||
}
|
||||
)
|
||||
AlphaSlider(
|
||||
@ -450,7 +449,7 @@ class AlertDialogs(
|
||||
controller = controller
|
||||
)
|
||||
IconButton(onClick = {
|
||||
property.value.setAny(null)
|
||||
setProperty(null)
|
||||
dismiss()
|
||||
}) {
|
||||
Icon(
|
||||
@ -463,6 +462,25 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorPickerPropertyDialog(
|
||||
property: PropertyPair<*>,
|
||||
dismiss: () -> Unit = {},
|
||||
) {
|
||||
var currentColor by remember {
|
||||
mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) })
|
||||
}
|
||||
|
||||
ColorPickerDialog(
|
||||
initialColor = currentColor,
|
||||
setProperty = {
|
||||
currentColor = it
|
||||
property.value.setAny(it?.toArgb())
|
||||
},
|
||||
dismiss = dismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseLocationDialog(
|
||||
property: PropertyPair<*>,
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
@ -36,6 +36,9 @@
|
||||
"friend_tracker": "Friend Tracker",
|
||||
"edit_rule": "Edit Rule",
|
||||
"file_imports": "File Imports",
|
||||
"theming": "Theming",
|
||||
"edit_theme": "Edit Theme",
|
||||
"manage_repos": "Manage Repositories",
|
||||
"social": "Social",
|
||||
"manage_scope": "Manage Scope",
|
||||
"messaging_preview": "Preview",
|
||||
@ -168,6 +171,9 @@
|
||||
"search_bar": "Search",
|
||||
"no_friends_map": "No friends on the map",
|
||||
"no_friends_found": "No friends found"
|
||||
},
|
||||
"theming": {
|
||||
"no_themes_hint": "No themes found"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
@ -293,6 +299,10 @@
|
||||
"name": "Change Language",
|
||||
"description": "Change the language of SnapEnhance"
|
||||
},
|
||||
"security_features": {
|
||||
"name": "Security Features",
|
||||
"description": "Access security features"
|
||||
},
|
||||
"file_imports": {
|
||||
"name": "File Imports",
|
||||
"description": "Import files for use in Snapchat"
|
||||
@ -304,6 +314,10 @@
|
||||
"logger_history": {
|
||||
"name": "Logger History",
|
||||
"description": "View the history of logged messages"
|
||||
},
|
||||
"theming": {
|
||||
"name": "Theming",
|
||||
"description": "Customize the look and feel of Snapchat"
|
||||
}
|
||||
},
|
||||
|
||||
@ -415,65 +429,9 @@
|
||||
"name": "Enable App Appearance Settings",
|
||||
"description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions"
|
||||
},
|
||||
"customize_ui": {
|
||||
"name": "Colors",
|
||||
"description": "Customize Snapchats Colors",
|
||||
"properties": {
|
||||
"theme_picker": {
|
||||
"name": "Theme Picker",
|
||||
"description": "Preset Snapchat Themes"
|
||||
},
|
||||
"colors": {
|
||||
"name": "Custom Colors",
|
||||
"description": "Customize Individual colors\nNote: Select Custom Colors on Theme Picker to use",
|
||||
"properties": {
|
||||
"text_color": {
|
||||
"name": "Main Text Color",
|
||||
"description": "Changes Snapchats main text color"
|
||||
},
|
||||
"chat_chat_text_color": {
|
||||
"name": "Main Friend Feed Text Color",
|
||||
"description": "Changes the text color of ( New Chat / New Snap And Chats / Typing / Calling / Missed call / Speaking / New Voice Note ) on the friend feed"
|
||||
},
|
||||
"pending_sending_text_color": {
|
||||
"name": "Secondary Friend Feed Text Color",
|
||||
"description": "Changes the text color of ( Delivered / Received / Sending / Opened / Tap To Chat / Hold To Replay / Replayed / Saved In Chat / Called ) on the friend feed"
|
||||
},
|
||||
"snap_with_sound_text_color": {
|
||||
"name": "Snaps With Sound Text Color",
|
||||
"description": "Changes the text color of ( New Snap ) on the friend feed\nNote: Video Snaps Only"
|
||||
},
|
||||
"snap_without_sound_text_color": {
|
||||
"name": "Snaps Without Sound Text Color",
|
||||
"description": "Changes the text color of ( New Snap ) on the friend feed\nNote: Video Snaps Only"
|
||||
},
|
||||
"background_color": {
|
||||
"name": "Background Color",
|
||||
"description": "Changes Snapchats background color"
|
||||
},
|
||||
"background_color_surface": {
|
||||
"name": "Background Surface Color",
|
||||
"description": "Changes Snapchats background surface color"
|
||||
},
|
||||
"friend_feed_conversations_line_color": {
|
||||
"name": "Conversations Line Color",
|
||||
"description": "Changes the line divider color that splits Conversations on the friend feed "
|
||||
},
|
||||
"action_menu_background_color": {
|
||||
"name": "Action Menu Background Color",
|
||||
"description": "Changes Snapchats chat action menu background color"
|
||||
},
|
||||
"action_menu_round_background_color": {
|
||||
"name": "Action Menu Round Background Color",
|
||||
"description": "Changes Snapchats chat action menu round background color"
|
||||
},
|
||||
"camera_grid_lines": {
|
||||
"name": "Camera Gridlines Color",
|
||||
"description": "Changes Snapchats Gridlines color on the Camera Preview\nNote: Enable the grid on the my camera settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"custom_theme": {
|
||||
"name": "Custom Theme",
|
||||
"description": "Customize Snapchat's Colors\nNote: if you choose a dark theme (like Amoled), you may need to enable the dark mode in Snapchat settings for better results"
|
||||
},
|
||||
"friend_feed_message_preview": {
|
||||
"name": "Friend Feed Message Preview",
|
||||
@ -1201,20 +1159,11 @@
|
||||
"always_light": "Always Light",
|
||||
"always_dark": "Always Dark"
|
||||
},
|
||||
"theme_picker": {
|
||||
"amoled_dark_mode": "AMOLED Dark Mode",
|
||||
"custom": "Custom Colors",
|
||||
"custom_theme": {
|
||||
"amoled_dark_mode": "Amoled Dark Mode",
|
||||
"custom": "Custom Themes (Use the Quick Actions to manage themes)",
|
||||
"material_you_light": "Material You Light (Android 12+)",
|
||||
"material_you_dark": "Material You Dark (Android 12+)",
|
||||
"light_blue": "Light Blue",
|
||||
"dark_blue": "Dark Blue",
|
||||
"earthy_autumn": "Earthy Autumn",
|
||||
"mint_chocolate": "Mint Chocolate",
|
||||
"ginger_snap": "Ginger Snap",
|
||||
"lemon_meringue": "Lemon Meringue",
|
||||
"lava_flow": "Lava Flow",
|
||||
"ocean_fog": "Ocean Fog",
|
||||
"alien_landscape": "Alien Landscape"
|
||||
"material_you_dark": "Material You Dark (Android 12+)"
|
||||
},
|
||||
"friend_feed_menu_buttons": {
|
||||
"auto_download": "\u2B07\uFE0F Auto Download",
|
||||
@ -1681,5 +1630,48 @@
|
||||
"date_input_invalid_year_range": "Invalid year",
|
||||
"date_input_invalid_not_allowed": "Invalid date",
|
||||
"date_range_input_invalid_range_input": "Invalid date range"
|
||||
},
|
||||
|
||||
"theming_attributes": {
|
||||
"sigColorTextPrimary": "Main Text Color",
|
||||
"sigColorChatChat": "Main Friend Feed Text Color",
|
||||
"sigColorBackgroundSurface": "Background Surface Color",
|
||||
"sigColorChatPendingSending": "Secondary Friend Feed Text Color",
|
||||
"sigColorChatSnapWithSound": "Snaps With Sound Text Color",
|
||||
"sigColorChatSnapWithoutSound": "Snaps Without Sound Text Color",
|
||||
"actionSheetDescriptionTextColor": "Action Menu Description Text Color",
|
||||
"sigColorBackgroundMain": "Background Color",
|
||||
"listBackgroundDrawable": "Conversation list Background",
|
||||
"sigColorChatConversationsLine": "Conversations Line Color",
|
||||
"actionSheetBackgroundDrawable": "Action Menu Background Color",
|
||||
"actionSheetRoundedBackgroundDrawable": "Action Menu Round Background Color",
|
||||
"sigColorIconPrimary": "Action Menu Icon Color",
|
||||
"sigExceptionColorCameraGridLines": "Camera Gridlines Color",
|
||||
"listDivider": "List Divider Color",
|
||||
"sigColorIconSecondary": "Secondary Icon Color",
|
||||
"itemShapeFillColor": "Item Shape Fill Color",
|
||||
"ringColor": "Ring Color",
|
||||
"ringStartColor": "Ring Start Color",
|
||||
"sigColorLayoutPlaceholder": "Layout Placeholder Color",
|
||||
"scButtonColor": "Snapchat Button Color",
|
||||
"recipientPillBackgroundDrawable": "Recipient Pill Background",
|
||||
"boxBackgroundColor": "Box Background Color",
|
||||
"editTextColor": "Edit Text Color",
|
||||
"chipBackgroundColor": "Chip Background Color",
|
||||
"recipientInputStyle": "Recipient Input Style",
|
||||
"rangeFillColor": "Range Fill Color",
|
||||
"pstsIndicatorColor": "PSTS Indicator Color",
|
||||
"pstsTabBackground": "PSTS Tab Background",
|
||||
"pstsDividerColor": "PSTS Divider Color",
|
||||
"tabTextColor": "Tab Text Color",
|
||||
"statusBarForeground": "Status Bar Foreground Color",
|
||||
"statusBarBackground": "Status Bar Background Color",
|
||||
"strokeColor": "Stroke Color",
|
||||
"storyReplayViewRingColor": "Story Replay View Ring Color",
|
||||
"sigColorButtonPrimary": "Primary Button Color",
|
||||
"sigColorBaseAppYellow": "Base App Yellow Color",
|
||||
"sigColorBackgroundSurfaceTranslucent": "Translucent Background Surface Color",
|
||||
"sigColorStoryRingFriendsFeedStoryRing": "Story Ring Friends Feed Story Ring Color",
|
||||
"sigColorStoryRingDiscoverTabThumbnailStoryRing": "Story Ring Discover Tab Thumbnail Story Ring Color"
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,8 @@ enum class FileHandleScope(
|
||||
INTERNAL("internal"),
|
||||
LOCALE("locale"),
|
||||
USER_IMPORT("user_import"),
|
||||
COMPOSER("composer");
|
||||
COMPOSER("composer"),
|
||||
THEME("theme");
|
||||
|
||||
companion object {
|
||||
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }
|
||||
@ -31,8 +32,8 @@ enum class InternalFileHandleType(
|
||||
CONFIG("config", "config.json"),
|
||||
MAPPINGS("mappings", "mappings.json"),
|
||||
MESSAGE_LOGGER("message_logger", "message_logger.db", isDatabase = true),
|
||||
PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt");
|
||||
|
||||
PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"),
|
||||
SIF("sif", "libsif.so");
|
||||
|
||||
fun resolve(context: Context): File = if (isDatabase) {
|
||||
context.getDatabasePath(fileName)
|
||||
|
@ -39,7 +39,7 @@ class ConversationInfo(
|
||||
val usernames: List<String>
|
||||
)
|
||||
|
||||
class TrackerLog(
|
||||
data class TrackerLog(
|
||||
val id: Int,
|
||||
val timestamp: Long,
|
||||
val conversationId: String,
|
||||
|
@ -31,7 +31,7 @@ class Experimental : ConfigContainer() {
|
||||
val composerLogs = boolean("composer_logs")
|
||||
}
|
||||
|
||||
class NativeHooks : ConfigContainer(hasGlobalState = true) {
|
||||
class NativeHooks : ConfigContainer() {
|
||||
val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() }
|
||||
val disableBitmoji = boolean("disable_bitmoji")
|
||||
val customEmojiFont = string("custom_emoji_font") {
|
||||
@ -40,7 +40,6 @@ class Experimental : ConfigContainer() {
|
||||
addFlags(ConfigFlag.USER_IMPORT)
|
||||
filenameFilter = { it.endsWith(".ttf") }
|
||||
}
|
||||
val remapExecutable = boolean("remap_executable") { requireRestart(); addNotices(FeatureNotice.INTERNAL_BEHAVIOR, FeatureNotice.UNSTABLE) }
|
||||
}
|
||||
|
||||
class E2EEConfig : ConfigContainer(hasGlobalState = true) {
|
||||
|
@ -19,46 +19,18 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
}
|
||||
|
||||
|
||||
class ColorsConfig : ConfigContainer() {
|
||||
val textColor = color("text_color")
|
||||
val chatChatTextColor = color("chat_chat_text_color")
|
||||
val pendingSendingTextColor = color("pending_sending_text_color")
|
||||
val snapWithSoundTextColor = color("snap_with_sound_text_color")
|
||||
val snapWithoutSoundTextColor = color("snap_without_sound_text_color")
|
||||
val backgroundColor = color("background_color")
|
||||
val backgroundColorSurface = color("background_color_surface")
|
||||
val friendFeedConversationsLineColor = color("friend_feed_conversations_line_color")
|
||||
val actionMenuBackgroundColor = color("action_menu_background_color")
|
||||
val actionMenuRoundBackgroundColor = color("action_menu_round_background_color")
|
||||
val cameraGridLines = color("camera_grid_lines")
|
||||
}
|
||||
|
||||
inner class CustomizeUIConfig : ConfigContainer() {
|
||||
val themePicker = unique("theme_picker",
|
||||
"custom",
|
||||
"amoled_dark_mode",
|
||||
"material_you_light",
|
||||
"material_you_dark",
|
||||
"light_blue",
|
||||
"dark_blue",
|
||||
"earthy_autumn",
|
||||
"mint_chocolate",
|
||||
"ginger_snap",
|
||||
"lemon_meringue",
|
||||
"lava_flow",
|
||||
"ocean_fog",
|
||||
"alien_landscape",
|
||||
)
|
||||
val colors = container("colors", ColorsConfig()) { requireRestart() }
|
||||
}
|
||||
|
||||
val friendFeedMenuButtons = multiple(
|
||||
"friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray()
|
||||
).apply {
|
||||
set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key))
|
||||
}
|
||||
val autoCloseFriendFeedMenu = boolean("auto_close_friend_feed_menu")
|
||||
val customizeUi = container("customize_ui", CustomizeUIConfig()) { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
|
||||
val customTheme = unique("custom_theme",
|
||||
"amoled_dark_mode",
|
||||
"material_you_light",
|
||||
"material_you_dark",
|
||||
"custom",
|
||||
) { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
|
||||
val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() }
|
||||
val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
|
||||
val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() }
|
||||
|
@ -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",
|
||||
)
|
||||
)
|
@ -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
|
||||
)
|
@ -20,8 +20,23 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
|
||||
}
|
||||
|
||||
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
|
||||
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
|
||||
ClipData.newPlainText(label, data))
|
||||
runCatching {
|
||||
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
|
||||
ClipData.newPlainText(label, data))
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getTextFromClipboard(): String? {
|
||||
return runCatching {
|
||||
getSystemService(android.content.ClipboardManager::class.java).primaryClip
|
||||
?.takeIf { it.itemCount > 0 }
|
||||
?.getItemAt(0)
|
||||
?.text?.toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun Context.getUrlFromClipboard(): String? {
|
||||
return getTextFromClipboard()?.takeIf { it.startsWith("http") }
|
||||
}
|
||||
|
||||
fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor {
|
||||
|
@ -153,13 +153,11 @@ class ModContext(
|
||||
}
|
||||
|
||||
fun reloadNativeConfig() {
|
||||
if (config.experimental.nativeHooks.globalState != true) return
|
||||
native.loadNativeConfig(
|
||||
NativeConfig(
|
||||
disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(),
|
||||
disableMetrics = config.global.disableMetrics.get(),
|
||||
composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true,
|
||||
remapExecutable = config.experimental.nativeHooks.remapExecutable.get(),
|
||||
customEmojiFontPath = getCustomEmojiFontPath(this)
|
||||
)
|
||||
)
|
||||
|
@ -4,7 +4,8 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import dalvik.system.BaseDexClassLoader
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Cancel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -14,6 +15,9 @@ import me.rhunk.snapenhance.bridge.SyncCallback
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.rhunk.snapenhance.common.ReceiversConfig
|
||||
import me.rhunk.snapenhance.common.action.EnumAction
|
||||
import me.rhunk.snapenhance.common.bridge.FileHandleScope
|
||||
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
|
||||
import me.rhunk.snapenhance.common.bridge.toWrapper
|
||||
import me.rhunk.snapenhance.common.data.FriendStreaks
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
@ -27,7 +31,6 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater
|
||||
import me.rhunk.snapenhance.core.util.hook.HookAdapter
|
||||
import me.rhunk.snapenhance.core.util.hook.HookStage
|
||||
import me.rhunk.snapenhance.core.util.hook.hook
|
||||
import me.rhunk.snapenhance.core.util.hook.hookConstructor
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@ -166,6 +169,8 @@ class SnapEnhance {
|
||||
}
|
||||
}
|
||||
|
||||
private var safeMode = false
|
||||
|
||||
private fun onActivityCreate(activity: Activity) {
|
||||
measureTimeMillis {
|
||||
with(appContext) {
|
||||
@ -173,6 +178,10 @@ class SnapEnhance {
|
||||
inAppOverlay.onActivityCreate(activity)
|
||||
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) }
|
||||
actionManager.onActivityCreate()
|
||||
|
||||
if (safeMode) {
|
||||
appContext.inAppOverlay.showStatusToast(Icons.Outlined.Cancel, "Failed to load security features! Snapchat may not work properly.", durationMs = 5000)
|
||||
}
|
||||
}
|
||||
}.also { time ->
|
||||
appContext.log.verbose("onActivityCreate took $time")
|
||||
@ -180,36 +189,58 @@ class SnapEnhance {
|
||||
}
|
||||
|
||||
private fun initNative() {
|
||||
// don't initialize native when not logged in
|
||||
if (
|
||||
!appContext.isLoggedIn() &&
|
||||
appContext.bridgeClient.getDebugProp("force_native_load", null) != "true"
|
||||
) return
|
||||
if (appContext.config.experimental.nativeHooks.globalState != true) return
|
||||
val lateInit = appContext.native.initOnce {
|
||||
nativeUnaryCallCallback = { request ->
|
||||
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
|
||||
request.buffer = buffer
|
||||
request.canceled = canceled
|
||||
}
|
||||
}
|
||||
appContext.reloadNativeConfig()
|
||||
}
|
||||
|
||||
if (appContext.bridgeClient.getDebugProp("disable_sif", "false") != "true") {
|
||||
runCatching {
|
||||
appContext.native.loadSharedLibrary(
|
||||
appContext.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.SIF.key)
|
||||
.toWrapper()
|
||||
.readBytes()
|
||||
.takeIf {
|
||||
it.isNotEmpty()
|
||||
} ?: throw IllegalStateException("buffer is empty")
|
||||
)
|
||||
appContext.log.verbose("loaded sif")
|
||||
}.onFailure {
|
||||
safeMode = true
|
||||
appContext.log.error("Failed to load sif", it)
|
||||
}
|
||||
} else {
|
||||
appContext.log.warn("sif is disabled")
|
||||
}
|
||||
|
||||
lateinit var unhook: () -> Unit
|
||||
Runtime::class.java.declaredMethods.first {
|
||||
it.name == "loadLibrary0" && it.parameterTypes.contentEquals(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Class::class.java, String::class.java)
|
||||
else arrayOf(ClassLoader::class.java, String::class.java)
|
||||
)
|
||||
}.hook(HookStage.AFTER) { param ->
|
||||
val libName = param.arg<String>(1)
|
||||
if (libName != "client") return@hook
|
||||
unhook()
|
||||
appContext.native.initOnce {
|
||||
nativeUnaryCallCallback = { request ->
|
||||
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
|
||||
request.buffer = buffer
|
||||
request.canceled = canceled
|
||||
}
|
||||
}.apply {
|
||||
if (safeMode) {
|
||||
hook(HookStage.BEFORE) { param ->
|
||||
if (param.arg<String>(1) != "scplugin") return@hook
|
||||
appContext.log.warn("Can't load scplugin in safe mode")
|
||||
Thread.sleep(Long.MAX_VALUE)
|
||||
}
|
||||
appContext.reloadNativeConfig()
|
||||
}
|
||||
BaseDexClassLoader::class.java.hookConstructor(HookStage.AFTER) {
|
||||
appContext.native.hideAnonymousDexFiles()
|
||||
}
|
||||
}.also { unhook = { it.unhook() } }
|
||||
|
||||
lateinit var unhook: () -> Unit
|
||||
hook(HookStage.AFTER) { param ->
|
||||
val libName = param.arg<String>(1)
|
||||
if (libName != "client") return@hook
|
||||
unhook()
|
||||
appContext.log.verbose("libclient lateInit")
|
||||
lateInit()
|
||||
}.also { unhook = { it.unhook() } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun initConfigListener() {
|
||||
|
@ -122,7 +122,7 @@ class FeatureManager(
|
||||
AccountSwitcher(),
|
||||
RemoveGroupsLockedStatus(),
|
||||
BypassMessageActionRestrictions(),
|
||||
CustomizeUI(),
|
||||
CustomTheming(),
|
||||
BetterLocation(),
|
||||
MediaFilePicker(),
|
||||
HideActiveMusic(),
|
||||
|
@ -11,7 +11,7 @@ import me.rhunk.snapenhance.core.util.LSPatchUpdater
|
||||
import me.rhunk.snapenhance.core.util.hook.HookStage
|
||||
import me.rhunk.snapenhance.core.util.hook.hook
|
||||
|
||||
class DeviceSpooferHook: Feature("device_spoofer") {
|
||||
class DeviceSpooferHook: Feature("Device Spoofer") {
|
||||
private fun hookInstallerPackageName() {
|
||||
context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param ->
|
||||
param.setResult("com.android.vending")
|
||||
|
@ -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)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -13,7 +13,6 @@ typedef struct {
|
||||
bool disable_bitmoji;
|
||||
bool disable_metrics;
|
||||
bool composer_hooks;
|
||||
bool remap_executable;
|
||||
char custom_emoji_font_path[256];
|
||||
} native_config_t;
|
||||
|
||||
|
@ -9,8 +9,5 @@ static pthread_mutex_t hook_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void inline SafeHook(void *addr, void *hook, void **original) {
|
||||
pthread_mutex_lock(&hook_mutex);
|
||||
DobbyHook(addr, hook, original);
|
||||
if (common::native_config->remap_executable) {
|
||||
mprotect((void *)((uintptr_t) *original & PAGE_MASK), PAGE_SIZE, PROT_EXEC);
|
||||
}
|
||||
pthread_mutex_unlock(&hook_mutex);
|
||||
}
|
54
native/jni/src/hooks/linker_hook.h
Normal file
54
native/jni/src/hooks/linker_hook.h
Normal 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);
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
#include "logger.h"
|
||||
#include "common.h"
|
||||
#include "dobby_helper.h"
|
||||
#include "hooks/linker_hook.h"
|
||||
#include "hooks/unary_call.h"
|
||||
#include "hooks/fstat_hook.h"
|
||||
#include "hooks/sqlite_mutex.h"
|
||||
@ -17,9 +18,6 @@
|
||||
bool JNICALL init(JNIEnv *env, jobject clazz) {
|
||||
LOGD("Initializing native");
|
||||
using namespace common;
|
||||
util::remap_sections([](const std::string &line, size_t size) {
|
||||
return line.find(BUILD_PACKAGE) != std::string::npos;
|
||||
}, native_config->remap_executable);
|
||||
|
||||
native_lib_object = env->NewGlobalRef(clazz);
|
||||
client_module = util::get_module("libclient.so");
|
||||
@ -66,7 +64,6 @@ void JNICALL load_config(JNIEnv *env, jobject, jobject config_object) {
|
||||
native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji");
|
||||
native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics");
|
||||
native_config->composer_hooks = GET_CONFIG_BOOL("composerHooks");
|
||||
native_config->remap_executable = GET_CONFIG_BOOL("remapExecutable");
|
||||
|
||||
memset(native_config->custom_emoji_font_path, 0, sizeof(native_config->custom_emoji_font_path));
|
||||
auto custom_emoji_font_path = env->GetObjectField(config_object, env->GetFieldID(native_config_clazz, "customEmojiFontPath", "Ljava/lang/String;"));
|
||||
@ -97,15 +94,6 @@ void JNICALL lock_database(JNIEnv *env, jobject, jstring database_name, jobject
|
||||
}
|
||||
}
|
||||
|
||||
void JNICALL hide_anonymous_dex_files(JNIEnv *, jobject) {
|
||||
util::remap_sections([](const std::string &line, size_t size) {
|
||||
return (
|
||||
(common::native_config->remap_executable && size == PAGE_SIZE && line.find("r-xp 00000000 00") != std::string::npos && line.find("[v") == std::string::npos) ||
|
||||
line.find("dalvik-DEX") != std::string::npos ||
|
||||
line.find("dalvik-classes") != std::string::npos
|
||||
);
|
||||
}, common::native_config->remap_executable);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) {
|
||||
common::java_vm = vm;
|
||||
@ -118,7 +106,9 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) {
|
||||
methods.push_back({"lockDatabase", "(Ljava/lang/String;Ljava/lang/Runnable;)V", (void *)lock_database});
|
||||
methods.push_back({"setComposerLoader", "(Ljava/lang/String;)V", (void *) ComposerHook::setComposerLoader});
|
||||
methods.push_back({"composerEval", "(Ljava/lang/String;)Ljava/lang/String;",(void *) ComposerHook::composerEval});
|
||||
methods.push_back({"hideAnonymousDexFiles", "()V", (void *)hide_anonymous_dex_files});
|
||||
methods.push_back({"addLinkerSharedLibrary", "(Ljava/lang/String;[B)V", (void *) LinkerHook::addLinkerSharedLibrary});
|
||||
|
||||
LinkerHook::init();
|
||||
|
||||
env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size());
|
||||
return JNI_VERSION_1_6;
|
||||
|
@ -52,46 +52,6 @@ namespace util {
|
||||
return { start_offset, end_offset - start_offset };
|
||||
}
|
||||
|
||||
static void remap_sections(std::function<bool(const std::string &, size_t)> filter, bool remove_read_permission) {
|
||||
char buff[256];
|
||||
auto maps = fopen("/proc/self/maps", "rt");
|
||||
|
||||
while (fgets(buff, sizeof buff, maps) != NULL) {
|
||||
int len = strlen(buff);
|
||||
if (len > 0 && buff[len - 1] == '\n') buff[--len] = '\0';
|
||||
|
||||
size_t start, end, offset;
|
||||
char flags[4];
|
||||
|
||||
if (sscanf(buff, "%zx-%zx %c%c%c%c %zx", &start, &end,
|
||||
&flags[0], &flags[1], &flags[2], &flags[3], &offset) != 7) continue;
|
||||
|
||||
if (!filter(buff, end - start)) continue;
|
||||
|
||||
auto section_size = end - start;
|
||||
auto section_ptr = mmap(0, section_size, PROT_READ | PROT_EXEC | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
|
||||
if (section_ptr == MAP_FAILED) {
|
||||
LOGE("mmap failed: %s", strerror(errno));
|
||||
break;
|
||||
}
|
||||
|
||||
memcpy(section_ptr, (void *)start, section_size);
|
||||
|
||||
if (mremap(section_ptr, section_size, section_size, MREMAP_MAYMOVE | MREMAP_FIXED, start) == MAP_FAILED) {
|
||||
LOGE("mremap failed: %s", strerror(errno));
|
||||
break;
|
||||
}
|
||||
|
||||
auto new_prot = (flags[0] == 'r' ? PROT_READ : 0) | (flags[1] == 'w' ? PROT_WRITE : 0) | (flags[2] == 'x' ? PROT_EXEC : 0);
|
||||
if (remove_read_permission && flags[0] == 'r' && flags[2] == 'x') {
|
||||
new_prot &= ~PROT_READ;
|
||||
}
|
||||
mprotect((void *)start, section_size, new_prot);
|
||||
}
|
||||
fclose(maps);
|
||||
}
|
||||
|
||||
static uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) {
|
||||
std::vector<char> bytes;
|
||||
std::vector<char> mask;
|
||||
|
@ -8,7 +8,5 @@ data class NativeConfig(
|
||||
@JvmField
|
||||
val composerHooks: Boolean = false,
|
||||
@JvmField
|
||||
val remapExecutable: Boolean = false,
|
||||
@JvmField
|
||||
val customEmojiFontPath: String? = null,
|
||||
)
|
@ -1,6 +1,9 @@
|
||||
package me.rhunk.snapenhance.nativelib
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("KotlinJniMissingFunction")
|
||||
class NativeLib {
|
||||
@ -11,19 +14,21 @@ class NativeLib {
|
||||
private set
|
||||
}
|
||||
|
||||
fun initOnce(callback: NativeLib.() -> Unit) {
|
||||
fun initOnce(callback: NativeLib.() -> Unit): () -> Unit {
|
||||
if (initialized) throw IllegalStateException("NativeLib already initialized")
|
||||
runCatching {
|
||||
return runCatching {
|
||||
System.loadLibrary(BuildConfig.NATIVE_NAME)
|
||||
initialized = true
|
||||
callback(this)
|
||||
if (!init()) {
|
||||
throw IllegalStateException("NativeLib init failed. Check logcat for more info")
|
||||
return@runCatching {
|
||||
if (!init()) {
|
||||
throw IllegalStateException("NativeLib init failed. Check logcat for more info")
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
initialized = false
|
||||
Log.e("SnapEnhance", "NativeLib init failed", it)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@ -54,10 +59,18 @@ class NativeLib {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
fun loadSharedLibrary(content: ByteArray) {
|
||||
if (!initialized) throw IllegalStateException("NativeLib not initialized")
|
||||
val generatedPath = "/data/app/${Random.nextLong().absoluteValue.toString(16)}.so"
|
||||
addLinkerSharedLibrary(generatedPath, content)
|
||||
System.load(generatedPath)
|
||||
}
|
||||
|
||||
private external fun init(): Boolean
|
||||
private external fun loadConfig(config: NativeConfig)
|
||||
private external fun lockDatabase(name: String, callback: Runnable)
|
||||
external fun setComposerLoader(code: String)
|
||||
external fun composerEval(code: String): String?
|
||||
external fun hideAnonymousDexFiles()
|
||||
private external fun addLinkerSharedLibrary(path: String, content: ByteArray)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user