diff --git a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/BottomBarDestination.kt b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/BottomBarDestination.kt index 129411b2..285acbd9 100644 --- a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/BottomBarDestination.kt +++ b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/BottomBarDestination.kt @@ -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), } diff --git a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/kpm.kt b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/kpm.kt new file mode 100644 index 00000000..ecbc59a6 --- /dev/null +++ b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/screen/kpm.kt @@ -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 +@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)) + } + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/util/KsuCli.kt b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/util/KsuCli.kt index 00321f60..fe873201 100644 --- a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/util/KsuCli.kt @@ -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 +} diff --git a/manager/app/src/main/java/shirkneko/zako/sukisu/ui/viewmodel/KpmViewModel.kt b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/viewmodel/KpmViewModel.kt new file mode 100644 index 00000000..9f430cfd --- /dev/null +++ b/manager/app/src/main/java/shirkneko/zako/sukisu/ui/viewmodel/KpmViewModel.kt @@ -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()) + 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 { + return try { + val output = printKpmModules() + parseModuleList(output) + } catch (e: Exception) { + emptyList() + } + } + + private fun parseModuleList(output: String): List { + 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 + ) +} \ No newline at end of file diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 86b37418..0a1c37db 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -236,4 +236,6 @@ Confirm installation? Installation of kpm module successful Installation of kpm module failed + kpm 参数 + kpm 控制