diff --git a/kernel/umount_manager.c b/kernel/umount_manager.c index 71ef5624..e143a644 100644 --- a/kernel/umount_manager.c +++ b/kernel/umount_manager.c @@ -59,6 +59,7 @@ static int init_default_entries(void) { "/product", true, 0 }, { "/system_ext", true, 0 }, { "/data/adb/modules", false, MNT_DETACH }, + { "/debug_ramdisk", false, MNT_DETACH }, }; for (int i = 0; i < ARRAY_SIZE(defaults); i++) { diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt index b9135477..6b528f5d 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.rounded.EnhancedEncryption @@ -43,6 +44,7 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination import com.ramcosta.composedestinations.generated.destinations.LogViewerScreenDestination +import com.ramcosta.composedestinations.generated.destinations.UmountManagerScreenDestination import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.sukisu.ultra.BuildConfig @@ -410,6 +412,19 @@ fun SettingScreen(navigator: DestinationsNavigator) { } ) } + val lkmMode = Natives.isLkmMode + KsuIsValid { + if (lkmMode) { + SettingItem( + icon = Icons.Filled.FolderOff, + title = stringResource(R.string.umount_path_manager), + summary = stringResource(R.string.umount_path_manager_summary), + onClick = { + navigator.navigate(UmountManagerScreenDestination) + } + ) + } + } if (showBottomsheet) { LogBottomSheet( @@ -452,8 +467,6 @@ fun SettingScreen(navigator: DestinationsNavigator) { } ) } - - val lkmMode = Natives.isLkmMode if (lkmMode) { UninstallItem(navigator) { loadingDialog.withLoading(it) @@ -1118,7 +1131,7 @@ fun SettingDropdown( } Icon( - imageVector = Icons.Filled.ArrowForward, + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp) diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt new file mode 100644 index 00000000..9a17c82b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/UmountManagerScreen.kt @@ -0,0 +1,442 @@ +package com.sukisu.ultra.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +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 com.sukisu.ultra.R +import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.component.ConfirmResult +import com.sukisu.ultra.ui.theme.CardConfig +import com.sukisu.ultra.ui.theme.getCardColors +import com.sukisu.ultra.ui.theme.getCardElevation +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SPACING_SMALL = 3.dp +private val SPACING_MEDIUM = 8.dp +private val SPACING_LARGE = 16.dp + +data class UmountPathEntry( + val path: String, + val checkMnt: Boolean, + val flags: Int, + val isDefault: Boolean +) + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +fun UmountManagerScreen(navigator: DestinationsNavigator) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackBarHost = LocalSnackbarHost.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val confirmDialog = rememberConfirmDialog() + + var pathList by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var showAddDialog by remember { mutableStateOf(false) } + + fun loadPaths() { + scope.launch(Dispatchers.IO) { + isLoading = true + val result = listUmountPaths() + val entries = parseUmountPaths(result) + withContext(Dispatchers.Main) { + pathList = entries + isLoading = false + } + } + } + + LaunchedEffect(Unit) { + loadPaths() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.umount_path_manager)) }, + navigationIcon = { + IconButton(onClick = { navigator.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { loadPaths() }) { + Icon(Icons.Filled.Refresh, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy( + alpha = CardConfig.cardAlpha + ) + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showAddDialog = true } + ) { + Icon(Icons.Filled.Add, contentDescription = null) + } + }, + snackbarHost = { SnackbarHost(snackBarHost) } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + colors = getCardColors(MaterialTheme.colorScheme.primaryContainer), + elevation = getCardElevation() + ) { + Column( + modifier = Modifier.padding(SPACING_LARGE) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + Text( + text = stringResource(R.string.umount_path_restart_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM) + ) { + items(pathList, key = { it.path }) { entry -> + UmountPathCard( + entry = entry, + onDelete = { + scope.launch(Dispatchers.IO) { + val success = removeUmountPath(entry.path) + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.umount_path_removed) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + ) + } + + item { + Spacer(modifier = Modifier.height(SPACING_LARGE)) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE), + horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM) + ) { + Button( + onClick = { + scope.launch { + if (confirmDialog.awaitConfirm( + title = context.getString(R.string.confirm_action), + content = context.getString(R.string.confirm_clear_custom_paths) + ) == ConfirmResult.Confirmed) { + withContext(Dispatchers.IO) { + val success = clearCustomUmountPaths() + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.custom_paths_cleared) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.DeleteForever, contentDescription = null) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.clear_custom_paths)) + } + + Button( + onClick = { + scope.launch(Dispatchers.IO) { + val success = applyUmountConfigToKernel() + withContext(Dispatchers.Main) { + if (success) { + snackBarHost.showSnackbar( + context.getString(R.string.config_applied) + ) + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Check, contentDescription = null) + Spacer(modifier = Modifier.width(SPACING_MEDIUM)) + Text(stringResource(R.string.apply_config)) + } + } + } + } + } + } + + if (showAddDialog) { + AddUmountPathDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { path, checkMnt, flags -> + showAddDialog = false + + scope.launch(Dispatchers.IO) { + val success = addUmountPath(path, checkMnt, flags) + withContext(Dispatchers.Main) { + if (success) { + saveUmountConfig() + snackBarHost.showSnackbar( + context.getString(R.string.umount_path_added) + ) + loadPaths() + } else { + snackBarHost.showSnackbar( + context.getString(R.string.operation_failed) + ) + } + } + } + } + ) + } + } +} + +@Composable +fun UmountPathCard( + entry: UmountPathEntry, + onDelete: () -> Unit +) { + val confirmDialog = rememberConfirmDialog() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow), + elevation = getCardElevation() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(SPACING_LARGE), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = if (entry.isDefault) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(SPACING_LARGE)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.path, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = buildString { + append(context.getString(R.string.check_mount_type)) + append(": ") + append(if (entry.checkMnt) context.getString(R.string.yes) else context.getString(R.string.no)) + append(" | ") + append(context.getString(R.string.flags)) + append(": ") + append(entry.flags.toUmountFlagName(context)) + if (entry.isDefault) { + append(" | ") + append(context.getString(R.string.default_entry)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!entry.isDefault) { + IconButton( + onClick = { + scope.launch { + if (confirmDialog.awaitConfirm( + title = context.getString(R.string.confirm_delete), + content = context.getString(R.string.confirm_delete_umount_path, entry.path) + ) == ConfirmResult.Confirmed) { + onDelete() + } + } + } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + +@Composable +fun AddUmountPathDialog( + onDismiss: () -> Unit, + onConfirm: (String, Boolean, Int) -> Unit +) { + var path by rememberSaveable { mutableStateOf("") } + var checkMnt by rememberSaveable { mutableStateOf(false) } + var flags by rememberSaveable { mutableStateOf("-1") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_umount_path)) }, + text = { + Column { + OutlinedTextField( + value = path, + onValueChange = { path = it }, + label = { Text(stringResource(R.string.mount_path)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checkMnt, + onCheckedChange = { checkMnt = it } + ) + Spacer(modifier = Modifier.width(SPACING_SMALL)) + Text(stringResource(R.string.check_mount_type_overlay)) + } + + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + + OutlinedTextField( + value = flags, + onValueChange = { flags = it }, + label = { Text(stringResource(R.string.umount_flags)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { Text(stringResource(R.string.umount_flags_hint)) } + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val flagsInt = flags.toIntOrNull() ?: -1 + onConfirm(path, checkMnt, flagsInt) + }, + enabled = path.isNotBlank() + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +private fun parseUmountPaths(output: String): List { + val lines = output.lines().filter { it.isNotBlank() } + if (lines.size < 2) return emptyList() + + return lines.drop(2).mapNotNull { line -> + val parts = line.trim().split(Regex("\\s+")) + if (parts.size >= 4) { + UmountPathEntry( + path = parts[0], + checkMnt = parts[1].equals("true", ignoreCase = true), + flags = parts[2].toIntOrNull() ?: -1, + isDefault = parts[3].equals("Yes", ignoreCase = true) + ) + } else null + } +} + +private fun Int.toUmountFlagName(context: android.content.Context): String { + return when (this) { + -1 -> context.getString(R.string.mnt_detach) + else -> this.toString() + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt index 5236a3b6..4f2893f1 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt @@ -665,3 +665,56 @@ fun readUidScannerFile(): Boolean { false } } + +fun addUmountPath(path: String, checkMnt: Boolean, flags: Int): Boolean { + val shell = getRootShell() + val checkMntFlag = if (checkMnt) "--check-mnt" else "" + val flagsArg = if (flags >= 0) "--flags $flags" else "" + val cmd = "${getKsuDaemonPath()} umount add $path $checkMntFlag $flagsArg" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "add umount path $path result: $result") + return result +} + +fun removeUmountPath(path: String): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount remove $path" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "remove umount path $path result: $result") + return result +} + +fun listUmountPaths(): String { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount list" + return try { + runCmd(shell, cmd).trim() + } catch (e: Exception) { + Log.e(TAG, "Failed to list umount paths", e) + "" + } +} + +fun clearCustomUmountPaths(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount clear-custom" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "clear custom umount paths result: $result") + return result +} + +fun saveUmountConfig(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount save" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "save umount config result: $result") + return result +} + +fun applyUmountConfigToKernel(): Boolean { + val shell = getRootShell() + val cmd = "${getKsuDaemonPath()} umount apply" + val result = ShellUtils.fastCmdResult(shell, cmd) + Log.i(TAG, "apply umount config to kernel result: $result") + return result +} 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 c27db912..0cd10fc6 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -729,4 +729,26 @@ 当前管理器与此内核不兼容!请将内核升级至版本 %2$d 或以上(当前 %1$d) + Umount 路径管理 + 管理内核卸载路径 + 添加或删除路径后需要重启设备才能生效。系统会在下次启动时应用新的配置。 + 添加 Umount 路径 + 挂载路径 + 检查挂载类型 + 检查是否为 overlay 类型 + 卸载标志 + 0=正常卸载, 8=MNT_DETACH, -1=自动 + 标志 + 默认条目 + 确认删除 + 确定要删除路径 %s 吗? + 路径已添加,重启后生效 + 路径已删除,重启后生效 + 操作失败 + 确认操作 + 确定要清除所有自定义路径吗?(默认路径将保留) + 自定义路径已清除 + 清除自定义 + 应用配置 + 配置已应用到内核 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index fcc7b88f..3092da59 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -737,4 +737,27 @@ Important Note:\n The current manager is incompatible with this kernel! Please upgrade the kernel to version %2$d or higher (currently %1$d) + Umount Path Management + Manage kernel unmount paths + A reboot is required for changes to take effect. The system will apply the new configuration on the next boot. + Add Umount Path + Mount Path + Check Mount Type + Check if it is an overlay type + Unmount Flags + 0=Normal unmount, 8=MNT_DETACH, -1=Auto + Flags + Default Entry + Confirm Delete + Are you sure you want to delete the path %s? + Path added, will take effect after reboot + Path removed, will take effect after reboot + Operation failed + Confirm Action + Are you sure you want to clear all custom paths? (Default paths will be preserved) + Custom paths cleared + Clear Custom Paths + Apply Configuration + Configuration applied to kernel + MNT_DETACH diff --git a/userspace/ksud/src/umount_manager.rs b/userspace/ksud/src/umount_manager.rs index 5f9627e4..09df4245 100644 --- a/userspace/ksud/src/umount_manager.rs +++ b/userspace/ksud/src/umount_manager.rs @@ -170,7 +170,13 @@ impl UmountManager { UmountEntry { path: "/data/adb/modules".to_string(), check_mnt: false, - flags: -1, + flags: -1, // MNT_DETACH + is_default: true, + }, + UmountEntry { + path: "/debug_ramdisk".to_string(), + check_mnt: false, + flags: -1, // MNT_DETACH is_default: true, }, ]