manager: Implement editable and removable mount points for LKM

This commit is contained in:
ShirkNeko
2025-11-07 15:37:04 +08:00
parent 9ebddde0d5
commit 53d763cdf9
7 changed files with 564 additions and 4 deletions

View File

@@ -59,6 +59,7 @@ static int init_default_entries(void)
{ "/product", true, 0 }, { "/product", true, 0 },
{ "/system_ext", true, 0 }, { "/system_ext", true, 0 },
{ "/data/adb/modules", false, MNT_DETACH }, { "/data/adb/modules", false, MNT_DETACH },
{ "/debug_ramdisk", false, MNT_DETACH },
}; };
for (int i = 0; i < ARRAY_SIZE(defaults); i++) { for (int i = 0; i < ARRAY_SIZE(defaults); i++) {

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.automirrored.filled.Undo
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.EnhancedEncryption 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.AppProfileTemplateScreenDestination
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.LogViewerScreenDestination 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.generated.destinations.MoreSettingsScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.BuildConfig 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) { if (showBottomsheet) {
LogBottomSheet( LogBottomSheet(
@@ -452,8 +467,6 @@ fun SettingScreen(navigator: DestinationsNavigator) {
} }
) )
} }
val lkmMode = Natives.isLkmMode
if (lkmMode) { if (lkmMode) {
UninstallItem(navigator) { UninstallItem(navigator) {
loadingDialog.withLoading(it) loadingDialog.withLoading(it)
@@ -1118,7 +1131,7 @@ fun SettingDropdown(
} }
Icon( Icon(
imageVector = Icons.Filled.ArrowForward, imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)

View File

@@ -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<RootGraph>
@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<List<UmountPathEntry>>(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<UmountPathEntry> {
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()
}
}

View File

@@ -665,3 +665,56 @@ fun readUidScannerFile(): Boolean {
false 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
}

View File

@@ -729,4 +729,26 @@
<string name="incompatible_kernel_msg"> <string name="incompatible_kernel_msg">
当前管理器与此内核不兼容!请将内核升级至版本 %2$d 或以上(当前 %1$d 当前管理器与此内核不兼容!请将内核升级至版本 %2$d 或以上(当前 %1$d
</string> </string>
<string name="umount_path_manager">Umount 路径管理</string>
<string name="umount_path_manager_summary">管理内核卸载路径</string>
<string name="umount_path_restart_notice">添加或删除路径后需要重启设备才能生效。系统会在下次启动时应用新的配置。</string>
<string name="add_umount_path">添加 Umount 路径</string>
<string name="mount_path">挂载路径</string>
<string name="check_mount_type">检查挂载类型</string>
<string name="check_mount_type_overlay">检查是否为 overlay 类型</string>
<string name="umount_flags">卸载标志</string>
<string name="umount_flags_hint">0=正常卸载, 8=MNT_DETACH, -1=自动</string>
<string name="flags">标志</string>
<string name="default_entry">默认条目</string>
<string name="confirm_delete">确认删除</string>
<string name="confirm_delete_umount_path">确定要删除路径 %s 吗?</string>
<string name="umount_path_added">路径已添加,重启后生效</string>
<string name="umount_path_removed">路径已删除,重启后生效</string>
<string name="operation_failed">操作失败</string>
<string name="confirm_action">确认操作</string>
<string name="confirm_clear_custom_paths">确定要清除所有自定义路径吗?(默认路径将保留)</string>
<string name="custom_paths_cleared">自定义路径已清除</string>
<string name="clear_custom_paths">清除自定义</string>
<string name="apply_config">应用配置</string>
<string name="config_applied">配置已应用到内核</string>
</resources> </resources>

View File

@@ -737,4 +737,27 @@ Important Note:\n
<string name="incompatible_kernel_msg"> <string name="incompatible_kernel_msg">
The current manager is incompatible with this kernel! Please upgrade the kernel to version %2$d or higher (currently %1$d) The current manager is incompatible with this kernel! Please upgrade the kernel to version %2$d or higher (currently %1$d)
</string> </string>
<string name="umount_path_manager">Umount Path Management</string>
<string name="umount_path_manager_summary">Manage kernel unmount paths</string>
<string name="umount_path_restart_notice">A reboot is required for changes to take effect. The system will apply the new configuration on the next boot.</string>
<string name="add_umount_path">Add Umount Path</string>
<string name="mount_path">Mount Path</string>
<string name="check_mount_type">Check Mount Type</string>
<string name="check_mount_type_overlay">Check if it is an overlay type</string>
<string name="umount_flags">Unmount Flags</string>
<string name="umount_flags_hint">0=Normal unmount, 8=MNT_DETACH, -1=Auto</string>
<string name="flags">Flags</string>
<string name="default_entry">Default Entry</string>
<string name="confirm_delete">Confirm Delete</string>
<string name="confirm_delete_umount_path">Are you sure you want to delete the path %s?</string>
<string name="umount_path_added">Path added, will take effect after reboot</string>
<string name="umount_path_removed">Path removed, will take effect after reboot</string>
<string name="operation_failed">Operation failed</string>
<string name="confirm_action">Confirm Action</string>
<string name="confirm_clear_custom_paths">Are you sure you want to clear all custom paths? (Default paths will be preserved)</string>
<string name="custom_paths_cleared">Custom paths cleared</string>
<string name="clear_custom_paths">Clear Custom Paths</string>
<string name="apply_config">Apply Configuration</string>
<string name="config_applied">Configuration applied to kernel</string>
<string name="mnt_detach">MNT_DETACH</string>
</resources> </resources>

View File

@@ -170,7 +170,13 @@ impl UmountManager {
UmountEntry { UmountEntry {
path: "/data/adb/modules".to_string(), path: "/data/adb/modules".to_string(),
check_mnt: false, 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, is_default: true,
}, },
] ]