manager: Add action.sh for user to manually trigger modules' functionality from manager (#2156)

Commits:
- manager: Add `action.sh` for user to manually trigger modules'
functionality from manager
- manager: Optimize ModuleItem
- manager: uninstall button: TextButton -> FilledTonalButton
- Optimize `run_action` function

Commit Author & Thank:
- @lightsummer233
- @lingqiqi5211
- [APatch](https://github.com/bmax121/APatch)

Demo Show:

![image](https://github.com/user-attachments/assets/a5778a86-fa60-485f-ac49-2b581711f60e)

---------

Co-authored-by: Light summer <93428659+lightsummer233@users.noreply.github.com>
This commit is contained in:
铃柒柒
2024-10-27 17:07:13 +08:00
committed by GitHub
parent 7b3e732404
commit aefb1aaed2
10 changed files with 319 additions and 84 deletions

View File

@@ -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<RootGraph>
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),
)
}
}
)
}

View File

@@ -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,9 +403,15 @@ private fun ModuleList(
}
}
ModuleItem(module, isChecked, updatedModule.first, onUninstall = {
ModuleItem(
navigator = navigator,
module = module,
isChecked = isChecked,
updateUrl = updatedModule.first,
onUninstall = {
scope.launch { onModuleUninstall(module) }
}, onCheckChanged = {
},
onCheckChanged = {
scope.launch {
val success = loadingDialog.withLoading {
withContext(Dispatchers.IO) {
@@ -418,7 +435,8 @@ private fun ModuleList(
snackBarHost.showSnackbar(message.format(module.name))
}
}
}, onUpdate = {
},
onUpdate = {
scope.launch {
onModuleUpdate(
module,
@@ -427,9 +445,11 @@ private fun ModuleList(
"${module.name}-${updatedModule.second}.zip"
)
}
}, onClick = {
},
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, "", {}, {}, {}, {})
}

View File

@@ -188,6 +188,30 @@ fun flashModule(
}
}
fun runModuleAction(
moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit
): Boolean {
val shell = getRootShell()
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
override fun onAddElement(s: String?) {
onStdout(s ?: "")
}
}
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
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 {

View File

@@ -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

View File

@@ -105,6 +105,7 @@
<string name="settings_check_update">检查更新</string>
<string name="settings_check_update_summary">在应用启动后自动检查是否有最新版</string>
<string name="grant_root_failed">获取 root 失败!</string>
<string name="action">执行</string>
<string name="open">打开</string>
<string name="enable_web_debugging">启用 WebView 调试</string>
<string name="enable_web_debugging_summary">可用于调试 WebUI ,请仅在需要时启用。</string>

View File

@@ -107,6 +107,7 @@
<string name="settings_check_update">Check update</string>
<string name="settings_check_update_summary">Automatically check for updates when opening the app</string>
<string name="grant_root_failed">Failed to grant root!</string>
<string name="action">Action</string>
<string name="open">Open</string>
<string name="enable_web_debugging">Enable WebView debugging</string>
<string name="enable_web_debugging_summary">Can be used to debug WebUI, please enable only when needed.</string>

View File

@@ -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" }

View File

@@ -223,6 +223,12 @@ enum Module {
id: String,
},
/// run action for module <id>
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(),
}

View File

@@ -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";

View File

@@ -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<HashMap<String, String>> {
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());