commit 97aed78894b02f8c19354243d4cfe81d9e384126
parent adf2eff024dc0ba5e2bdaa847b52f16250aeb8f4
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed, 22 May 2024 22:39:41 +0200

feat(scripting): module updater

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt | 44+++++++++++++++++++++++++++++++++-----------
Mapp/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt | 10++--------
Mapp/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt | 69+++++++++------------------------------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt | 44++------------------------------------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt | 52++++++++++++++++++++++++++++++++++++++++------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt | 4++--
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt | 29+++++++++++++++++++++++++++++
8 files changed, 173 insertions(+), 151 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -13,12 +13,13 @@ import me.rhunk.snapenhance.common.scripting.bindings.BindingSide import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.type.readModuleInfo +import me.rhunk.snapenhance.common.util.ktx.await import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor import me.rhunk.snapenhance.scripting.impl.IPCListeners import me.rhunk.snapenhance.scripting.impl.ManagerIPC import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig import me.rhunk.snapenhance.storage.isScriptEnabled -import me.rhunk.snapenhance.storage.syncScripts import okhttp3.OkHttpClient import okhttp3.Request import java.io.File @@ -56,23 +57,23 @@ class RemoteScriptManager( private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>() private val ipcListeners = IPCListeners() + fun getSyncedModules(): List<ModuleInfo> { + return cachedModuleInfo.values.toList() + } + fun sync() { cachedModuleInfo.clear() getScriptFileNames().forEach { name -> runCatching { getScriptInputStream(name) { stream -> - stream?.use { - runtime.getModuleInfo(it).also { info -> - cachedModuleInfo[name] = info - } + stream?.bufferedReader()?.readModuleInfo()?.also { + cachedModuleInfo[name] = it } } }.onFailure { context.log.error("Failed to load module info for $name", it) } } - - context.database.syncScripts(cachedModuleInfo.values.toList()) } fun init() { @@ -133,7 +134,8 @@ class RemoteScriptManager( } fun importFromUrl( - url: String + url: String, + filepath: String? = null ): ModuleInfo { val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() if (!response.isSuccessful) { @@ -142,14 +144,14 @@ class RemoteScriptManager( response.body.byteStream().use { inputStream -> val bufferedInputStream = inputStream.buffered() bufferedInputStream.mark(0) - val moduleInfo = runtime.readModuleInfo(bufferedInputStream.bufferedReader()) + val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo() bufferedInputStream.reset() - val scriptPath = moduleInfo.name + ".js" + val scriptPath = filepath ?: (moduleInfo.name + ".js") val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath) ?: throw Exception("Failed to create script file") - context.androidContext.contentResolver.openOutputStream(scriptFile.uri)?.use { output -> + context.androidContext.contentResolver.openOutputStream(scriptFile.uri, "wt")?.use { output -> bufferedInputStream.copyTo(output) } @@ -160,6 +162,26 @@ class RemoteScriptManager( } } + suspend fun checkForUpdate(inputModuleInfo: ModuleInfo): ModuleInfo? { + return runCatching { + context.log.verbose("checking for updates for ${inputModuleInfo.name} ${inputModuleInfo.updateUrl}") + val response = okHttpClient.newCall(Request.Builder().url(inputModuleInfo.updateUrl ?: return@runCatching null).build()).await() + if (!response.isSuccessful) { + return@runCatching null + } + response.body.byteStream().use { inputStream -> + val reader = inputStream.buffered().bufferedReader() + val moduleInfo = reader.readModuleInfo() + moduleInfo.takeIf { + it.version != inputModuleInfo.version + } + } + }.onFailure { + context.log.error("Failed to check for updates", it) + }.getOrNull() + } + + override fun getEnabledScripts(): List<String> { return runCatching { getScriptFileNames().filter { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt @@ -56,14 +56,8 @@ class AppDatabase( "expirationTimestamp BIGINT", "length INTEGER" ), - "scripts" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "name VARCHAR NOT NULL", - "version VARCHAR NOT NULL", - "displayName VARCHAR", - "description VARCHAR", - "author VARCHAR NOT NULL", - "enabled BOOLEAN" + "enabled_scripts" to listOf( + "name VARCHAR PRIMARY KEY", ), "tracker_rules" to listOf( "id INTEGER PRIMARY KEY AUTOINCREMENT", diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt @@ -2,72 +2,22 @@ package me.rhunk.snapenhance.storage import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.common.scripting.type.ModuleInfo -import me.rhunk.snapenhance.common.util.ktx.getInteger -import me.rhunk.snapenhance.common.util.ktx.getStringOrNull -fun AppDatabase.getScripts(): List<ModuleInfo> { - return database.rawQuery("SELECT * FROM scripts ORDER BY id DESC", null).use { cursor -> - val scripts = mutableListOf<ModuleInfo>() - while (cursor.moveToNext()) { - scripts.add( - ModuleInfo( - name = cursor.getStringOrNull("name")!!, - version = cursor.getStringOrNull("version")!!, - displayName = cursor.getStringOrNull("displayName"), - description = cursor.getStringOrNull("description"), - author = cursor.getStringOrNull("author"), - grantedPermissions = emptyList() - ) - ) - } - scripts - } -} - fun AppDatabase.setScriptEnabled(name: String, enabled: Boolean) { executeAsync { - database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf( - if (enabled) 1 else 0, - name - )) + if (enabled) { + database.execSQL("INSERT OR REPLACE INTO enabled_scripts (name) VALUES (?)", arrayOf(name)) + } else { + database.execSQL("DELETE FROM enabled_scripts WHERE name = ?", arrayOf(name)) + } } } fun AppDatabase.isScriptEnabled(name: String): Boolean { - return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor -> - if (!cursor.moveToFirst()) return@use false - cursor.getInteger("enabled") == 1 - } -} - -fun AppDatabase.syncScripts(availableScripts: List<ModuleInfo>) { - runBlocking(executor.asCoroutineDispatcher()) { - val enabledScripts = getScripts() - val enabledScriptPaths = enabledScripts.map { it.name } - val availableScriptPaths = availableScripts.map { it.name } - - enabledScripts.forEach { script -> - if (!availableScriptPaths.contains(script.name)) { - database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name)) - } - } - - availableScripts.forEach { script -> - if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) { - database.execSQL( - "INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)", - arrayOf( - script.name, - script.version, - script.displayName, - script.description, - script.author, - 0 - ) - ) - } + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT * FROM enabled_scripts WHERE name = ?", arrayOf(name)).use { + it.moveToNext() } } -}- \ No newline at end of file +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt @@ -23,6 +23,7 @@ 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 @@ -32,7 +33,7 @@ import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager 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.storage.getScripts +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher import me.rhunk.snapenhance.storage.isScriptEnabled import me.rhunk.snapenhance.storage.setScriptEnabled import me.rhunk.snapenhance.ui.manager.Routes @@ -142,6 +143,7 @@ class ScriptingRoot : Routes.Route() { @Composable private fun ModuleActions( script: ModuleInfo, + canUpdate: Boolean, dismiss: () -> Unit ) { Dialog( @@ -153,8 +155,28 @@ class ScriptingRoot : Routes.Route() { .padding(2.dp), ) { val actions = remember { - mapOf<Pair<String, ImageVector>, suspend () -> Unit>( - ("Edit Module" to Icons.Default.Edit) to { + mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply { + if (canUpdate) { + put("Update Module" to Icons.Default.Download) { + dismiss() + context.shortToast("Updating script ${script.name}...") + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found") + context.scriptManager.unloadScript(modulePath) + val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath) + context.shortToast("Updated ${script.name} to version ${moduleInfo.version}") + context.database.setScriptEnabled(script.name, false) + withContext(context.database.executor.asCoroutineDispatcher()) { + reloadDispatcher.dispatch() + } + }.onFailure { + context.log.error("Failed to update module", it) + context.shortToast("Failed to update module. Check logs for more details") + } + } + } + + put("Edit Module" to Icons.Default.Edit) { runCatching { val modulePath = context.scriptManager.getModulePath(script.name)!! context.androidContext.startActivity( @@ -170,8 +192,8 @@ class ScriptingRoot : Routes.Route() { context.log.error("Failed to open module file", it) context.shortToast("Failed to open module file. Check logs for more details") } - }, - ("Clear Module Data" to Icons.Default.Save) to { + } + put("Clear Module Data" to Icons.Default.Save) { runCatching { context.scriptManager.getModuleDataFolder(script.name) .deleteRecursively() @@ -181,8 +203,8 @@ class ScriptingRoot : Routes.Route() { context.log.error("Failed to clear module data", it) context.shortToast("Failed to clear module data. Check logs for more details") } - }, - ("Delete Module" to Icons.Default.DeleteOutline) to { + } + put("Delete Module" to Icons.Default.DeleteOutline) { context.scriptManager.apply { runCatching { val modulePath = getModulePath(script.name)!! @@ -197,7 +219,7 @@ class ScriptingRoot : Routes.Route() { } } } - ) + }.toMap() } LazyColumn( @@ -243,14 +265,26 @@ class ScriptingRoot : Routes.Route() { @Composable fun ModuleItem(script: ModuleInfo) { - var enabled by rememberAsyncMutableState(defaultValue = false) { + var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) { context.database.isScriptEnabled(script.name) } - var openSettings by remember { - mutableStateOf(false) + var openSettings by remember(script) { mutableStateOf(false) } + var openActions by remember { mutableStateOf(false) } + + val dispatcher = rememberAsyncUpdateDispatcher() + val reloadCallback = remember { suspend { dispatcher.dispatch() } } + val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) { + context.scriptManager.checkForUpdate(script) } - var openActions by remember { - mutableStateOf(false) + + LaunchedEffect(Unit) { + reloadDispatcher.addCallback(reloadCallback) + } + + DisposableEffect(Unit) { + onDispose { + reloadDispatcher.removeCallback(reloadCallback) + } } Card( @@ -286,6 +320,9 @@ class ScriptingRoot : Routes.Route() { ) { Text(text = script.displayName ?: script.name, fontSize = 20.sp) Text(text = script.description ?: "No description", fontSize = 14.sp) + latestUpdate?.let { + Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } IconButton(onClick = { openActions = !openActions @@ -333,7 +370,10 @@ class ScriptingRoot : Routes.Route() { } if (openActions) { - ModuleActions(script) { openActions = false } + ModuleActions( + script = script, + canUpdate = latestUpdate != null, + ) { openActions = false } } } @@ -416,7 +456,7 @@ class ScriptingRoot : Routes.Route() { updateDispatcher = reloadDispatcher ) { context.scriptManager.sync() - context.database.getScripts() + context.scriptManager.getSyncedModules() } val coroutineScope = rememberCoroutineScope() @@ -477,7 +517,7 @@ class ScriptingRoot : Routes.Route() { ) } } - items(scriptModules.size) { index -> + items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index -> ModuleItem(scriptModules[index]) } item { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt @@ -5,9 +5,8 @@ import android.os.ParcelFileDescriptor import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.logger.AbstractLogger -import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.type.readModuleInfo import org.mozilla.javascript.ScriptableObject -import java.io.BufferedReader import java.io.InputStream open class ScriptRuntime( @@ -35,45 +34,6 @@ open class ScriptRuntime( return modules.values.find { it.moduleInfo.name == name } } - fun readModuleInfo(reader: BufferedReader): ModuleInfo { - val header = reader.readLine() - if (!header.startsWith("// ==SE_module==")) { - throw Exception("Invalid module header") - } - - val properties = mutableMapOf<String, String>() - while (true) { - val line = reader.readLine() - if (line.startsWith("// ==/SE_module==")) { - break - } - val split = line.replaceFirst("//", "").split(":") - if (split.size != 2) { - throw Exception("Invalid module property") - } - properties[split[0].trim()] = split[1].trim() - } - - return ModuleInfo( - name = properties["name"]?.also { - if (!it.matches(Regex("[a-z_]+"))) { - throw Exception("Invalid module name : Only lowercase letters and underscores are allowed") - } - } ?: throw Exception("Missing module name"), - version = properties["version"] ?: throw Exception("Missing module version"), - displayName = properties["displayName"], - description = properties["description"], - author = properties["author"], - minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(), - minSEVersion = properties["minSEVersion"]?.toLongOrNull(), - grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(), - ) - } - - fun getModuleInfo(inputStream: InputStream): ModuleInfo { - return readModuleInfo(inputStream.bufferedReader()) - } - fun removeModule(scriptPath: String) { modules.remove(scriptPath) } @@ -94,7 +54,7 @@ open class ScriptRuntime( fun load(scriptPath: String, content: InputStream): JSModule { logger.info("Loading module $scriptPath") val bufferedReader = content.bufferedReader() - val moduleInfo = readModuleInfo(bufferedReader) + val moduleInfo = bufferedReader.readModuleInfo() if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) { throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})") diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt @@ -1,28 +1,57 @@ package me.rhunk.snapenhance.common.scripting.type +import java.io.BufferedReader + data class ModuleInfo( val name: String, val version: String, val displayName: String? = null, val description: String? = null, + val updateUrl: String? = null, val author: String? = null, val minSnapchatVersion: Long? = null, val minSEVersion: Long? = null, val grantedPermissions: List<String>, ) { - override fun equals(other: Any?): Boolean { - if (other !is ModuleInfo) return false - if (other === this) return true - return name == other.name && - version == other.version && - displayName == other.displayName && - description == other.description && - author == other.author - } - fun ensurePermissionGranted(permission: Permissions) { if (!grantedPermissions.contains(permission.key)) { throw AssertionError("Permission $permission is not granted") } } -}- \ No newline at end of file +} + +fun BufferedReader.readModuleInfo(): ModuleInfo { + val header = readLine() + if (!header.startsWith("// ==SE_module==")) { + throw Exception("Invalid module header") + } + + val properties = mutableMapOf<String, String>() + while (true) { + val line = readLine() + if (line.startsWith("// ==/SE_module==")) { + break + } + val split = line.replaceFirst("//", "").split(":", limit = 2) + if (split.size != 2) { + throw Exception("Invalid module property") + } + properties[split[0].trim()] = split[1].trim() + } + + return ModuleInfo( + name = properties["name"]?.also { + if (!it.matches(Regex("[a-z_]+"))) { + throw Exception("Invalid module name : Only lowercase letters and underscores are allowed") + } + } ?: throw Exception("Missing module name"), + version = properties["version"] ?: throw Exception("Missing module version"), + displayName = properties["displayName"], + description = properties["description"], + updateUrl = properties["updateUrl"], + author = properties["author"], + minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(), + minSEVersion = properties["minSEVersion"]?.toLongOrNull(), + grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(), + ) +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt @@ -61,7 +61,7 @@ fun <T> rememberAsyncMutableState( defaultValue: T, updateDispatcher: AsyncUpdateDispatcher? = null, keys: Array<*> = emptyArray<Any>(), - getter: () -> T, + getter: suspend () -> T, ): MutableState<T> { return rememberCommonState( initialState = { mutableStateOf(defaultValue) }, @@ -82,7 +82,7 @@ fun <T> rememberAsyncMutableStateList( defaultValue: List<T>, updateDispatcher: AsyncUpdateDispatcher? = null, keys: Array<*> = emptyArray<Any>(), - getter: () -> List<T>, + getter: suspend () -> List<T>, ): SnapshotStateList<T> { return rememberCommonState( initialState = { mutableStateListOf<T>().apply { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.common.util.ktx + +import kotlinx.coroutines.CompletionHandler +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import okio.IOException +import kotlin.coroutines.resumeWithException + +suspend inline fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + val callback = object: CompletionHandler, Callback { + override fun invoke(cause: Throwable?) { + runCatching { cancel() } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resumeWith(runCatching { response }) + } + } + enqueue(callback) + continuation.invokeOnCancellation(callback) + } +}