diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/ExecuteModuleAction.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/ExecuteModuleAction.kt new file mode 100644 index 000000000000..69480997d73f --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/ExecuteModuleAction.kt @@ -0,0 +1,143 @@ +package me.weishu.kernelsu.ui.screen + +import android.os.Environment +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.component.KeyEventBlocker +import me.weishu.kernelsu.ui.util.LocalSnackbarHost +import me.weishu.kernelsu.ui.util.runModuleAction +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +@Destination +fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) { + var text by rememberSaveable { mutableStateOf("") } + val logContent = rememberSaveable { StringBuilder() } + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + var actionResult: Boolean + + LaunchedEffect(Unit) { + if (text.isNotEmpty()) { + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + runModuleAction( + moduleId = moduleId, + onStdout = { + text += "$it\n" + logContent.append(it).append("\n") + }, + onStderr = { + logContent.append(it).append("\n") + } + ).let { + actionResult = it + } + } + if (actionResult) navigator.popBackStack() + } + + Scaffold( + topBar = { + TopBar( + onBack = { + navigator.popBackStack() + }, + onSave = { + scope.launch { + val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) + val date = format.format(Date()) + val file = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "KernelSU_module_action_log_${date}.log" + ) + file.writeText(logContent.toString()) + snackBarHost.showSnackbar("Log saved to ${file.absolutePath}") + } + } + ) + }, + snackbarHost = { SnackbarHost(snackBarHost) } + ) { innerPadding -> + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + Column( + modifier = Modifier + .fillMaxSize(1f) + .padding(innerPadding) + .verticalScroll(scrollState), + ) { + LaunchedEffect(text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(8.dp), + text = text, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = FontFamily.Monospace, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) { + TopAppBar( + title = { Text(stringResource(R.string.action)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } + }, + actions = { + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.save_log), + ) + } + } + ) +} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt index f30e8f22b110..75bedce17fb7 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt @@ -25,17 +25,20 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Wysiwyg import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -46,10 +49,8 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -77,8 +78,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -121,7 +124,11 @@ fun ModuleScreen(navigator: DestinationsNavigator) { Scaffold( topBar = { - TopBar(scrollBehavior = scrollBehavior) + TopAppBar( + scrollBehavior = scrollBehavior, + title = { Text(stringResource(R.string.module)) }, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) }, floatingActionButton = { if (hideInstallButton) { @@ -147,8 +154,9 @@ fun ModuleScreen(navigator: DestinationsNavigator) { ExtendedFloatingActionButton( onClick = { // select the zip file to install - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "application/zip" + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/zip" + } selectZipLauncher.launch(intent) }, icon = { Icon(Icons.Filled.Add, moduleInstall) }, @@ -174,8 +182,10 @@ fun ModuleScreen(navigator: DestinationsNavigator) { ) } } + else -> { ModuleList( + navigator, viewModel = viewModel, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), boxModifier = Modifier.padding(innerPadding), @@ -200,9 +210,10 @@ fun ModuleScreen(navigator: DestinationsNavigator) { } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModuleList( + navigator: DestinationsNavigator, viewModel: ModuleViewModel, modifier: Modifier = Modifier, boxModifier: Modifier = Modifier, @@ -392,44 +403,53 @@ private fun ModuleList( } } - ModuleItem(module, isChecked, updatedModule.first, onUninstall = { - scope.launch { onModuleUninstall(module) } - }, onCheckChanged = { - scope.launch { - val success = loadingDialog.withLoading { - withContext(Dispatchers.IO) { - toggleModule(module.id, !isChecked) + ModuleItem( + navigator = navigator, + module = module, + isChecked = isChecked, + updateUrl = updatedModule.first, + onUninstall = { + scope.launch { onModuleUninstall(module) } + }, + onCheckChanged = { + scope.launch { + val success = loadingDialog.withLoading { + withContext(Dispatchers.IO) { + toggleModule(module.id, !isChecked) + } + } + if (success) { + isChecked = it + viewModel.fetchModuleList() + + val result = snackBarHost.showSnackbar( + message = rebootToApply, + actionLabel = reboot, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + reboot() + } + } else { + val message = if (isChecked) failedDisable else failedEnable + snackBarHost.showSnackbar(message.format(module.name)) } } - if (success) { - isChecked = it - viewModel.fetchModuleList() - - val result = snackBarHost.showSnackbar( - message = rebootToApply, - actionLabel = reboot, - duration = SnackbarDuration.Long + }, + onUpdate = { + scope.launch { + onModuleUpdate( + module, + updatedModule.third, + updatedModule.first, + "${module.name}-${updatedModule.second}.zip" ) - if (result == SnackbarResult.ActionPerformed) { - reboot() - } - } else { - val message = if (isChecked) failedDisable else failedEnable - snackBarHost.showSnackbar(message.format(module.name)) } + }, + onClick = { + onClickModule(it.id, it.name, it.hasWebUi) } - }, onUpdate = { - scope.launch { - onModuleUpdate( - module, - updatedModule.third, - updatedModule.first, - "${module.name}-${updatedModule.second}.zip" - ) - } - }, onClick = { - onClickModule(it.id, it.name, it.hasWebUi) - }) + ) // fix last item shadow incomplete in LazyColumn Spacer(Modifier.height(1.dp)) @@ -443,20 +463,9 @@ private fun ModuleList( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TopBar( - scrollBehavior: TopAppBarScrollBehavior? = null -) { - TopAppBar( - scrollBehavior = scrollBehavior, - title = { Text(stringResource(R.string.module)) }, - windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - ) -} - -@Composable -private fun ModuleItem( +fun ModuleItem( + navigator: DestinationsNavigator, module: ModuleViewModel.ModuleInfo, isChecked: Boolean, updateUrl: String, @@ -494,7 +503,7 @@ private fun ModuleItem( ) } } - .padding(24.dp, 16.dp, 24.dp, 0.dp) + .padding(22.dp, 18.dp, 22.dp, 12.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -503,7 +512,9 @@ private fun ModuleItem( val moduleVersion = stringResource(id = R.string.module_version) val moduleAuthor = stringResource(id = R.string.module_author) - Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Column( + modifier = Modifier.fillMaxWidth(0.8f) + ) { Text( text = module.name, fontSize = MaterialTheme.typography.titleMedium.fontSize, @@ -558,57 +569,95 @@ private fun ModuleItem( textDecoration = textDecoration ) - Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(thickness = Dp.Hairline) + Spacer(modifier = Modifier.height(4.dp)) + Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + + if (module.hasActionScript) { + FilledTonalButton( + modifier = Modifier.defaultMinSize(52.dp, 32.dp), + onClick = { navigator.navigate(ExecuteModuleActionScreenDestination(module.id)) }, + contentPadding = ButtonDefaults.TextButtonContentPadding + ) { + Icon( + modifier = Modifier + .padding(end = 7.dp) + .size(20.dp), + imageVector = Icons.Outlined.PlayArrow, + contentDescription = null + ) + Text( + text = stringResource(R.string.action), + fontFamily = MaterialTheme.typography.labelMedium.fontFamily, + fontSize = MaterialTheme.typography.labelMedium.fontSize + ) + } + + Spacer(modifier = Modifier.weight(0.1f, true)) + } + + if (module.hasWebUi) { + FilledTonalButton( + modifier = Modifier.defaultMinSize(52.dp, 32.dp), + onClick = { onClick(module) }, + interactionSource = interactionSource, + contentPadding = ButtonDefaults.TextButtonContentPadding + ) { + if (!module.hasActionScript) { + Icon( + modifier = Modifier + .padding(end = 7.dp) + .size(20.dp), + imageVector = Icons.AutoMirrored.Outlined.Wysiwyg, + contentDescription = null + ) + } + Text( + fontFamily = MaterialTheme.typography.labelMedium.fontFamily, + fontSize = MaterialTheme.typography.labelMedium.fontSize, + text = stringResource(R.string.open) + ) + } + } + Spacer(modifier = Modifier.weight(1f, true)) if (updateUrl.isNotEmpty()) { Button( - modifier = Modifier - .padding(0.dp) - .defaultMinSize(52.dp, 32.dp), + modifier = Modifier.defaultMinSize(52.dp, 32.dp), onClick = { onUpdate(module) }, - shape = RoundedCornerShape(6.dp), - contentPadding = PaddingValues(0.dp) + shape = ButtonDefaults.textShape, + contentPadding = ButtonDefaults.TextButtonContentPadding ) { Text( fontFamily = MaterialTheme.typography.labelMedium.fontFamily, fontSize = MaterialTheme.typography.labelMedium.fontSize, - text = stringResource(R.string.module_update), + text = stringResource(R.string.module_update) ) } + + Spacer(modifier = Modifier.weight(0.1f, true)) } - TextButton( + FilledTonalButton( + modifier = Modifier.defaultMinSize(52.dp, 32.dp), enabled = !module.remove, onClick = { onUninstall(module) }, + contentPadding = ButtonDefaults.TextButtonContentPadding ) { Text( fontFamily = MaterialTheme.typography.labelMedium.fontFamily, fontSize = MaterialTheme.typography.labelMedium.fontSize, - text = stringResource(R.string.uninstall), + text = stringResource(R.string.uninstall) ) } - - if (module.hasWebUi) { - TextButton( - onClick = { onClick(module) }, - interactionSource = interactionSource - ) { - Text( - fontFamily = MaterialTheme.typography.labelMedium.fontFamily, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - text = stringResource(R.string.open), - ) - } - } } } } @@ -626,9 +675,10 @@ fun ModuleItemPreview() { description = "I am a test module and i do nothing but show a very long description", enabled = true, update = true, - remove = true, + remove = false, updateJson = "", hasWebUi = false, + hasActionScript = false ) - ModuleItem(module, true, "", {}, {}, {}, {}) + ModuleItem(EmptyDestinationsNavigator, module, true, "", {}, {}, {}, {}) } \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt index 2376876589e1..32a94b7e1768 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt @@ -188,6 +188,30 @@ fun flashModule( } } +fun runModuleAction( + moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit +): Boolean { + val shell = getRootShell() + + val stdoutCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStdout(s ?: "") + } + } + + val stderrCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStderr(s ?: "") + } + } + + val result = shell.newJob().add("${getKsuDaemonPath()} module action $moduleId") + .to(stdoutCallback, stderrCallback).exec() + Log.i("KernelSU", "Module runAction result: $result") + + return result.isSuccess +} + fun restoreBoot( onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit ): Boolean { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt index 013453fbfeed..4533e6e5ffc4 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt @@ -36,6 +36,7 @@ class ModuleViewModel : ViewModel() { val remove: Boolean, val updateJson: String, val hasWebUi: Boolean, + val hasActionScript: Boolean, ) data class ModuleUpdateInfo( @@ -87,7 +88,6 @@ class ModuleViewModel : ViewModel() { .map { obj -> ModuleInfo( obj.getString("id"), - obj.optString("name"), obj.optString("author", "Unknown"), obj.optString("version", "Unknown"), @@ -97,7 +97,8 @@ class ModuleViewModel : ViewModel() { obj.getBoolean("update"), obj.getBoolean("remove"), obj.optString("updateJson"), - obj.optBoolean("web") + obj.optBoolean("web"), + obj.optBoolean("action") ) }.toList() isNeedRefresh = false diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 7553f80ae2df..950c69e5c257 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -105,6 +105,7 @@ 检查更新 在应用启动后自动检查是否有最新版 获取 root 失败! + 执行 打开 启用 WebView 调试 可用于调试 WebUI ,请仅在需要时启用。 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index a9be58cfc9e6..e9d058e9e0bd 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -107,6 +107,7 @@ Check update Automatically check for updates when opening the app Failed to grant root! + Action Open Enable WebView debugging Can be used to debug WebUI, please enable only when needed. diff --git a/manager/gradle/libs.versions.toml b/manager/gradle/libs.versions.toml index e739e139319d..42e9ce44741d 100644 --- a/manager/gradle/libs.versions.toml +++ b/manager/gradle/libs.versions.toml @@ -8,7 +8,7 @@ navigation = "2.8.3" activity-compose = "1.9.3" kotlinx-coroutines = "1.9.0" coil-compose = "2.7.0" -compose-destination = "2.1.0-beta13" +compose-destination = "2.1.0-beta14" sheets-compose-dialogs = "1.3.0" markdown = "4.6.2" webkit = "1.12.1" @@ -52,7 +52,7 @@ androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } com-github-topjohnwu-libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } com-github-topjohnwu-libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } -com-github-topjohnwu-libsu-io= { group = "com.github.topjohnwu.libsu", name = "io", version.ref = "libsu" } +com-github-topjohnwu-libsu-io = { group = "com.github.topjohnwu.libsu", name = "io", version.ref = "libsu" } dev-rikka-rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version.ref = "parcelablelist" } diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index cd96a360f346..14717e62df78 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -223,6 +223,12 @@ enum Module { id: String, }, + /// run action for module + Action { + // module id + id: String, + }, + /// list all modules List, @@ -306,6 +312,7 @@ pub fn run() -> Result<()> { Module::Uninstall { id } => module::uninstall_module(&id), Module::Enable { id } => module::enable_module(&id), Module::Disable { id } => module::disable_module(&id), + Module::Action { id } => module::run_action(&id), Module::List => module::list_modules(), Module::Shrink => module::shrink_ksu_images(), } diff --git a/userspace/ksud/src/defs.rs b/userspace/ksud/src/defs.rs index dd71a0f63e82..d5514501d140 100644 --- a/userspace/ksud/src/defs.rs +++ b/userspace/ksud/src/defs.rs @@ -30,6 +30,7 @@ pub const SYSTEM_RW_DIR: &str = concatcp!(MODULE_DIR, ".rw/"); pub const TEMP_DIR: &str = "/debug_ramdisk"; pub const MODULE_WEB_DIR: &str = "webroot"; +pub const MODULE_ACTION_SH: &str = "action.sh"; pub const DISABLE_FILE_NAME: &str = "disable"; pub const UPDATE_FILE_NAME: &str = "update"; pub const REMOVE_FILE_NAME: &str = "remove"; diff --git a/userspace/ksud/src/module.rs b/userspace/ksud/src/module.rs index ac3ff213b62f..28c5df405795 100644 --- a/userspace/ksud/src/module.rs +++ b/userspace/ksud/src/module.rs @@ -570,6 +570,11 @@ pub fn uninstall_module(id: &str) -> Result<()> { }) } +pub fn run_action(id: &str) -> Result<()> { + let action_script_path = format!("/data/adb/modules/{}/action.sh", id); + exec_script(&action_script_path, true) +} + fn _enable_module(module_dir: &str, mid: &str, enable: bool) -> Result<()> { let src_module_path = format!("{module_dir}/{mid}"); let src_module = Path::new(&src_module_path); @@ -668,11 +673,13 @@ fn _list_modules(path: &str) -> Vec> { let update = path.join(defs::UPDATE_FILE_NAME).exists(); let remove = path.join(defs::REMOVE_FILE_NAME).exists(); let web = path.join(defs::MODULE_WEB_DIR).exists(); + let action = path.join(defs::MODULE_ACTION_SH).exists(); module_prop_map.insert("enabled".to_owned(), enabled.to_string()); module_prop_map.insert("update".to_owned(), update.to_string()); module_prop_map.insert("remove".to_owned(), remove.to_string()); module_prop_map.insert("web".to_owned(), web.to_string()); + module_prop_map.insert("action".to_owned(), action.to_string()); if result.is_err() { warn!("Failed to parse module.prop: {}", module_prop.display());