commit c9526931e8cc5cc8202cecb701dd6570931713a2 parent 80e37306a4a52ef79aedf6c2a1acaf0601a1fc26 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 23:48:34 +0100 feat(scripting): auto reload - refactor ScriptsSection Diffstat:
15 files changed, 246 insertions(+), 40 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.scripting + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class AutoReloadHandler( + private val coroutineScope: CoroutineScope, + private val onReload: (DocumentFile) -> Unit, +) { + private val files = mutableListOf<DocumentFile>() + private val lastModifiedMap = mutableMapOf<Uri, Long>() + + fun addFile(file: DocumentFile) { + files.add(file) + lastModifiedMap[file.uri] = file.lastModified() + } + + fun start() { + coroutineScope.launch(Dispatchers.IO) { + while (true) { + files.forEach { file -> + val lastModified = lastModifiedMap[file.uri] ?: return@forEach + runCatching { + val newLastModified = file.lastModified() + if (newLastModified > lastModified) { + lastModifiedMap[file.uri] = newLastModified + onReload(file) + } + }.onFailure { + it.printStackTrace() + } + } + delay(1000) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.scripting import android.net.Uri import androidx.documentfile.provider.DocumentFile import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener import me.rhunk.snapenhance.bridge.scripting.IPCListener import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.common.scripting.ScriptRuntime @@ -15,12 +16,30 @@ import me.rhunk.snapenhance.scripting.impl.RemoteScriptConfig import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager import java.io.File import java.io.InputStream +import kotlin.system.exitProcess class RemoteScriptManager( val context: RemoteSideContext, ) : IScripting.Stub() { val runtime = ScriptRuntime(context.androidContext, context.log) + private var autoReloadListener: AutoReloadListener? = null + private val autoReloadHandler by lazy { + AutoReloadHandler(context.coroutineScope) { + runCatching { + autoReloadListener?.restartApp() + if (context.config.root.scripting.autoReload.getNullable() == "all") { + exitProcess(1) + } + }.onFailure { + context.log.warn("Failed to restart app") + autoReloadListener = null + } + }.apply { + start() + } + } + private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>() private val ipcListeners = IPCListeners() @@ -57,6 +76,9 @@ class RemoteScriptManager( fun loadScript(name: String) { val content = getScriptContent(name) ?: return + if (context.config.root.scripting.autoReload.getNullable() != null) { + autoReloadHandler.addFile(getScriptsFolder()?.findFile(name) ?: return) + } runtime.load(name, content) } @@ -73,7 +95,7 @@ class RemoteScriptManager( } } - private fun getScriptsFolder() = runCatching { + fun getScriptsFolder() = runCatching { DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get())) }.onFailure { context.log.warn("Failed to get scripts folder") @@ -141,4 +163,8 @@ class RemoteScriptManager( context.log.error("Failed to perform config transaction", it) }.getOrDefault("") } + + override fun registerAutoReloadListener(listener: AutoReloadListener?) { + autoReloadListener = listener + } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt @@ -4,6 +4,8 @@ import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.scripting.impl.ui.components.Node import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType import me.rhunk.snapenhance.scripting.impl.ui.components.impl.RowColumnNode import org.mozilla.javascript.Context import org.mozilla.javascript.Function @@ -14,13 +16,20 @@ class InterfaceBuilder { val nodes = mutableListOf<Node>() var onDisposeCallback: (() -> Unit)? = null - private fun createNode(type: NodeType, block: Node.() -> Unit): Node { return Node(type).apply(block).also { nodes.add(it) } } fun onDispose(block: () -> Unit) { - onDisposeCallback = block + nodes.add(ActionNode(ActionType.DISPOSE, callback = block)) + } + + fun onLaunched(block: () -> Unit) { + onLaunched(Unit, block) + } + + fun onLaunched(key: Any, block: () -> Unit) { + nodes.add(ActionNode(ActionType.LAUNCHED, key, block)) } fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt @@ -6,5 +6,6 @@ enum class NodeType { SWITCH, BUTTON, SLIDER, - LIST + LIST, + ACTION } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.scripting.impl.ui.components.impl + +import me.rhunk.snapenhance.scripting.impl.ui.components.Node +import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType + +enum class ActionType { + LAUNCHED, + DISPOSE +} + +class ActionNode( + val actionType: ActionType, + val key: Any = Unit, + val callback: () -> Unit +): Node(NodeType.ACTION)+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt @@ -16,6 +16,8 @@ import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.scripting.impl.ui.InterfaceBuilder import me.rhunk.snapenhance.scripting.impl.ui.components.Node import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType import kotlin.math.abs @@ -68,6 +70,26 @@ private fun DrawNode(node: Node) { } when (node.type) { + NodeType.ACTION -> { + when ((node as ActionNode).actionType) { + ActionType.LAUNCHED -> { + LaunchedEffect(node.key) { + runCallbackSafe { + node.callback() + } + } + } + ActionType.DISPOSE -> { + DisposableEffect(Unit) { + onDispose { + runCallbackSafe { + node.callback() + } + } + } + } + } + } NodeType.COLUMN -> { Column( verticalArrangement = arrangement as? Arrangement.Vertical ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.Top, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt @@ -1,9 +1,12 @@ package me.rhunk.snapenhance.ui.manager.sections.scripting +import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -11,16 +14,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.chooseFolder import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState class ScriptsSection : Section() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + @Composable fun ModuleItem(script: ModuleInfo) { var enabled by remember { @@ -50,22 +62,11 @@ class ScriptsSection : Section() { .weight(1f) .padding(end = 8.dp) ) { - Text( - text = script.name, - fontSize = 20.sp, - ) - Text( - text = script.description ?: "No description", - fontSize = 14.sp, - ) + Text(text = script.name, fontSize = 20.sp,) + Text(text = script.description ?: "No description", fontSize = 14.sp,) } - IconButton(onClick = { - openSettings = !openSettings - }) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - ) + IconButton(onClick = { openSettings = !openSettings }) { + Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) } Switch( checked = enabled, @@ -85,43 +86,94 @@ class ScriptsSection : Section() { } } + @Composable + override fun FloatingActionButton() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + ExtendedFloatingActionButton( + onClick = { + + }, + icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, + text = { + Text(text = "Import from URL") + }, + ) + ExtendedFloatingActionButton( + onClick = { + context.scriptManager.getScriptsFolder()?.let { + context.androidContext.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = it.uri + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + }, + icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, + text = { + Text(text = "Open Scripts Folder") + }, + ) + } + } + @Composable fun ScriptSettings(script: ModuleInfo) { + var settingsError by remember { + mutableStateOf(null as Throwable?) + } + val settingsInterface = remember { val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null - (module.extras["im"] as? InterfaceManager)?.buildInterface("settings") - } ?: run { + runCatching { + (module.extras["im"] as? InterfaceManager)?.buildInterface("settings") + }.onFailure { + settingsError = it + }.getOrNull() + } + + if (settingsInterface == null) { Text( - text = "This module does not have any settings", + text = settingsError?.message ?: "This module does not have any settings", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp) ) - return + } else { + ScriptInterface(interfaceBuilder = settingsInterface) } - - ScriptInterface(interfaceBuilder = settingsInterface) } @Composable override fun Content() { - var scriptModules by remember { - mutableStateOf(context.modDatabase.getScripts()) - } + var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } + var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } val coroutineScope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { - refreshing = true + fun syncScripts() { runCatching { + scriptingFolder = context.scriptManager.getScriptsFolder() context.scriptManager.sync() scriptModules = context.modDatabase.getScripts() }.onFailure { context.log.error("Failed to sync scripts", it) } + } + + LaunchedEffect(Unit) { + syncScripts() + } + + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { + refreshing = true + syncScripts() coroutineScope.launch { delay(300) refreshing = false @@ -138,7 +190,25 @@ class ScriptsSection : Section() { horizontalAlignment = Alignment.CenterHorizontally ) { item { - if (scriptModules.isEmpty()) { + if (scriptingFolder == null) { + Text( + text = "No scripts folder selected", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + activityLauncherHelper.chooseFolder { + context.config.root.scripting.moduleFolder.set(it) + context.config.writeConfig() + coroutineScope.launch { + syncScripts() + } + } + }) { + Text(text = "Select folder") + } + } else if (scriptModules.isEmpty()) { Text( text = "No scripts found", style = MaterialTheme.typography.bodySmall, diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.bridge.scripting; + +interface AutoReloadListener { + oneway void restartApp(); +}+ \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.bridge.scripting; import me.rhunk.snapenhance.bridge.scripting.IPCListener; +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener; interface IScripting { List<String> getEnabledScripts(); @@ -12,4 +13,6 @@ interface IScripting { void sendIPCMessage(String channel, String eventName, in String[] args); @nullable String configTransaction(String module, String action, @nullable String key, @nullable String value, boolean save); + + void registerAutoReloadListener(in AutoReloadListener listener); } \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -663,8 +663,8 @@ "name": "Module Folder", "description": "The folder where the scripts are located" }, - "hot_reload": { - "name": "Hot Reload", + "auto_reload": { + "name": "Auto Reload", "description": "Automatically reloads scripts when they change" }, "disable_log_anonymization": { @@ -790,6 +790,10 @@ "hide_friend": "Hide Friend", "hide_conversation": "Hide Conversation", "clear_conversation": "Clear Conversation from Friend Feed" + }, + "auto_reload": { + "snapchat_only": "Snapchat Only", + "all": "All (Snapchat + SnapEnhance)" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt @@ -6,6 +6,6 @@ import me.rhunk.snapenhance.common.config.ConfigFlag class Scripting : ConfigContainer() { val developerMode = boolean("developer_mode", false) { requireRestart() } val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER); requireRestart() } - val hotReload = boolean("hot_reload", false) + val autoReload = unique("auto_reload", "snapchat_only", "all") val disableLogAnonymization = boolean("disable_log_anonymization", false) { requireRestart() } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt @@ -81,7 +81,7 @@ class JSModule( putFunction(method.name) { args -> clazz.declaredMethods.find { it.name == method.name && it.parameterTypes.zip(args ?: emptyArray()).all { (type, arg) -> - type.isAssignableFrom(arg.javaClass) + type.isAssignableFrom(arg?.javaClass ?: return@all false) } }?.invoke(null, *args ?: emptyArray()) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt @@ -23,7 +23,7 @@ fun Scriptable.function(name: String): Function? { return this.get(name, this) as? Function } -fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any>?) -> Any?) { +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any?>?) -> Any?) { this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { override fun call( cx: Context?, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -62,7 +62,7 @@ class ModContext( val event = EventBus(this) val eventDispatcher = EventDispatcher(this) val native = NativeLib() - val scriptRuntime by lazy { CoreScriptRuntime(androidContext, log) } + val scriptRuntime by lazy { CoreScriptRuntime(this, log) } val messagingBridge = CoreMessagingBridge(this) val isDeveloper by lazy { config.scripting.developerMode.get() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt @@ -1,17 +1,18 @@ package me.rhunk.snapenhance.core.scripting -import android.content.Context +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.scripting.impl.CoreIPC import me.rhunk.snapenhance.core.scripting.impl.CoreScriptConfig import me.rhunk.snapenhance.core.scripting.impl.ScriptHooker class CoreScriptRuntime( - androidContext: Context, + private val modContext: ModContext, logger: AbstractLogger, -): ScriptRuntime(androidContext, logger) { +): ScriptRuntime(modContext.androidContext, logger) { private val scriptHookers = mutableListOf<ScriptHooker>() fun connect(scriptingInterface: IScripting) { @@ -31,6 +32,12 @@ class CoreScriptRuntime( logger.error("Failed to load script $path", it) } } + + registerAutoReloadListener(object : AutoReloadListener.Stub() { + override fun restartApp() { + modContext.softRestartApp() + } + }) } } } \ No newline at end of file