This commit is contained in:
liankong
2025-03-30 01:56:13 +08:00
5 changed files with 453 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import com.ramcosta.composedestinations.generated.destinations.HomeScreenDestina
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
import com.ramcosta.composedestinations.generated.destinations.SuperUserScreenDestination
import com.ramcosta.composedestinations.generated.destinations.SettingScreenDestination
import com.ramcosta.composedestinations.generated.destinations.KpmScreenDestination
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import shirkneko.zako.sukisu.R
@@ -22,5 +23,6 @@ enum class BottomBarDestination(
Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false),
SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.Security, Icons.Outlined.Security, true),
Module(ModuleScreenDestination, R.string.module, Icons.Filled.Apps, Icons.Outlined.Apps, true),
Kpm(KpmScreenDestination, R.string.kpm_title, Icons.Filled.Build, Icons.Outlined.Build, true),
Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false),
}

View File

@@ -0,0 +1,297 @@
package shirkneko.zako.sukisu.ui.screen
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.navigation.DestinationsNavigator
import kotlinx.coroutines.launch
import shirkneko.zako.sukisu.R
import shirkneko.zako.sukisu.ui.component.ConfirmResult
import shirkneko.zako.sukisu.ui.component.SearchAppBar
import shirkneko.zako.sukisu.ui.component.rememberConfirmDialog
import shirkneko.zako.sukisu.ui.component.rememberLoadingDialog
import shirkneko.zako.sukisu.ui.theme.getCardColors
import shirkneko.zako.sukisu.ui.theme.getCardElevation
import shirkneko.zako.sukisu.ui.viewmodel.KpmViewModel
import shirkneko.zako.sukisu.ui.util.loadKpmModule
import shirkneko.zako.sukisu.ui.util.unloadKpmModule
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun KpmScreen(
navigator: DestinationsNavigator,
viewModel: KpmViewModel = viewModel()
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackBarHost = remember { SnackbarHostState() }
val confirmDialog = rememberConfirmDialog()
val loadingDialog = rememberLoadingDialog()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val kpmInstall = stringResource(R.string.kpm_install)
val kpmInstallConfirm = stringResource(R.string.kpm_install_confirm)
val kpmInstallSuccess = stringResource(R.string.kpm_install_success)
val kpmInstallFailed = stringResource(R.string.kpm_install_failed)
val install = stringResource(R.string.install)
val cancel = stringResource(R.string.cancel)
val kpmUninstall = stringResource(R.string.kpm_uninstall)
val kpmUninstallConfirmTemplate = stringResource(R.string.kpm_uninstall_confirm)
val uninstall = stringResource(R.string.uninstall)
val kpmUninstallSuccess = stringResource(R.string.kpm_uninstall_success)
val kpmUninstallFailed = stringResource(R.string.kpm_uninstall_failed)
val selectPatchLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
scope.launch {
// 复制文件到临时目录
val tempFile = File(context.cacheDir, "temp_patch.kpm")
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
val confirmResult = confirmDialog.awaitConfirm(
title = kpmInstall,
content = kpmInstallConfirm,
confirm = install,
dismiss = cancel
)
if (confirmResult == ConfirmResult.Confirmed) {
val success = loadingDialog.withLoading {
loadKpmModule(tempFile.absolutePath)
}
Log.d("KsuCli", "loadKpmModule result: $success")
if (success == "success") {
viewModel.fetchModuleList()
snackBarHost.showSnackbar(
message = kpmInstallSuccess,
duration = SnackbarDuration.Long
)
} else {
// 修正为显示安装失败的消息
snackBarHost.showSnackbar(
message = kpmInstallFailed,
duration = SnackbarDuration.Long
)
}
}
tempFile.delete()
}
}
LaunchedEffect(Unit) {
if (viewModel.moduleList.isEmpty()) {
viewModel.fetchModuleList()
}
}
Scaffold(
topBar = {
SearchAppBar(
title = { Text(stringResource(R.string.kpm_title)) },
searchText = viewModel.search,
onSearchTextChange = { viewModel.search = it },
onClearClick = { viewModel.search = "" },
scrollBehavior = scrollBehavior,
dropdownContent = {
IconButton(onClick = { viewModel.fetchModuleList() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = stringResource(R.string.refresh)
)
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectPatchLauncher.launch(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "application/*"
}
)
},
icon = {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.kpm_install)
)
},
text = { Text(stringResource(R.string.kpm_install)) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
},
snackbarHost = { SnackbarHost(snackBarHost) }
) { padding ->
PullToRefreshBox(
onRefresh = { viewModel.fetchModuleList() },
isRefreshing = viewModel.isRefreshing,
modifier = Modifier.padding(padding)
) {
if (viewModel.moduleList.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.kpm_empty),
textAlign = TextAlign.Center
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(viewModel.moduleList) { module ->
val kpmUninstallConfirm = String.format(kpmUninstallConfirmTemplate, module.name)
KpmModuleItem(
module = module,
onUninstall = {
scope.launch {
val confirmResult = confirmDialog.awaitConfirm(
title = kpmUninstall,
content = kpmUninstallConfirm,
confirm = uninstall,
dismiss = cancel
)
if (confirmResult == ConfirmResult.Confirmed) {
val success = loadingDialog.withLoading {
unloadKpmModule(module.id)
}
Log.d("KsuCli", "unloadKpmModule result: $success")
if (success == "success") {
viewModel.fetchModuleList()
snackBarHost.showSnackbar(
message = kpmUninstallSuccess,
duration = SnackbarDuration.Long
)
} else {
snackBarHost.showSnackbar(
message = kpmUninstallFailed,
duration = SnackbarDuration.Long
)
}
}
}
},
onControl = {
viewModel.loadModuleDetail(module.id)
}
)
}
}
}
}
}
}
@Composable
private fun KpmModuleItem(
module: KpmViewModel.ModuleInfo,
onUninstall: () -> Unit,
onControl: () -> Unit
) {
ElevatedCard(
colors = getCardColors(MaterialTheme.colorScheme.secondaryContainer),
elevation = CardDefaults.cardElevation(defaultElevation = getCardElevation())
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = module.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${stringResource(R.string.kpm_version)}: ${module.version}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${stringResource(R.string.kpm_author)}: ${module.author}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${stringResource(R.string.kpm_args)}: ${module.args}",
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = module.description,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledTonalButton(
onClick = onControl
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = null
)
Text(stringResource(R.string.kpm_control))
}
FilledTonalButton(
onClick = onUninstall
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null
)
Text(stringResource(R.string.kpm_uninstall))
}
}
}
}
}

View File

@@ -480,3 +480,57 @@ fun susfsSUS_SU_Mode(): String {
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su mode")
return result
}
private fun getKpmmgrPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libkpmmgr.so"
}
fun loadKpmModule(path: String, args: String? = null): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} load $path ${args ?: ""}"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun unloadKpmModule(name: String): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} unload $name"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun getKpmModuleCount(): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} num"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun listKpmModules(): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} list"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun getKpmModuleInfo(name: String): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} info $name"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun controlKpmModule(name: String, args: String? = null): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} control $name ${args ?: ""}"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}
fun printKpmModules(): String {
val shell = getRootShell()
val cmd = "${getKpmmgrPath()} print"
val result = ShellUtils.fastCmd(shell, cmd)
return result
}

View File

@@ -0,0 +1,98 @@
package shirkneko.zako.sukisu.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import shirkneko.zako.sukisu.ui.util.*
class KpmViewModel : ViewModel() {
var moduleList by mutableStateOf(emptyList<ModuleInfo>())
private set
var search by mutableStateOf("")
internal set
var isRefreshing by mutableStateOf(false)
private set
var currentModuleDetail by mutableStateOf("")
private set
fun loadModuleDetail(moduleId: String) {
viewModelScope.launch {
currentModuleDetail = withContext(Dispatchers.IO) {
try {
getKpmModuleInfo(moduleId)
} catch (e: Exception) {
"无法获取模块详细信息: ${e.message}"
}
}
Log.d("KsuCli", "Module detail: $currentModuleDetail")
}
}
fun fetchModuleList() {
viewModelScope.launch {
isRefreshing = true
try {
val moduleCount = getKpmModuleCount()
Log.d("KsuCli", "Module count: $moduleCount")
val moduleInfo = listKpmModules()
Log.d("KsuCli", "Module info: $moduleInfo")
val modules = parseModuleList(moduleInfo)
moduleList = modules
} finally {
isRefreshing = false
}
}
}
private fun getInstalledKernelPatches(): List<ModuleInfo> {
return try {
val output = printKpmModules()
parseModuleList(output)
} catch (e: Exception) {
emptyList()
}
}
private fun parseModuleList(output: String): List<ModuleInfo> {
return output.split("\n").mapNotNull { line ->
if (line.isBlank()) return@mapNotNull null
val parts = line.split("|")
if (parts.size < 7) return@mapNotNull null
ModuleInfo(
id = parts[0].trim(),
name = parts[1].trim(),
version = parts[2].trim(),
author = parts[3].trim(),
description = parts[4].trim(),
args = parts[6].trim(),
enabled = true,
hasAction = controlKpmModule(parts[0].trim()).isNotBlank()
)
}
}
data class ModuleInfo(
val id: String,
val name: String,
val version: String,
val author: String,
val description: String,
val args: String,
val enabled: Boolean,
val hasAction: Boolean
)
}

View File

@@ -236,4 +236,6 @@
<string name="kpm_install_confirm">Confirm installation?</string>
<string name="kpm_install_success">Installation of kpm module successful</string>
<string name="kpm_install_failed">Installation of kpm module failed</string>
<string name="kpm_args">kpm 参数</string>
<string name="kpm_control">kpm 控制</string>
</resources>