diff --git a/manager/app/build.gradle.kts b/manager/app/build.gradle.kts index be818b79..6ffee22c 100644 --- a/manager/app/build.gradle.kts +++ b/manager/app/build.gradle.kts @@ -116,6 +116,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.documentfile) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/manager/app/src/main/assets/5_10-mkbootfs b/manager/app/src/main/assets/5_10-mkbootfs new file mode 100644 index 00000000..2af1167a Binary files /dev/null and b/manager/app/src/main/assets/5_10-mkbootfs differ diff --git a/manager/app/src/main/assets/5_15+-mkbootfs b/manager/app/src/main/assets/5_15+-mkbootfs new file mode 100644 index 00000000..2eca159b Binary files /dev/null and b/manager/app/src/main/assets/5_15+-mkbootfs differ diff --git a/manager/app/src/main/assets/kpimg b/manager/app/src/main/assets/kpimg new file mode 100644 index 00000000..e64eb858 Binary files /dev/null and b/manager/app/src/main/assets/kpimg differ diff --git a/manager/app/src/main/assets/kptools b/manager/app/src/main/assets/kptools new file mode 100644 index 00000000..f1a2a578 Binary files /dev/null and b/manager/app/src/main/assets/kptools differ diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt index 4ccff11e..0f0751ad 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt @@ -20,8 +20,13 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.sukisu.ultra.ui.util.getKpmVersion import androidx.compose.ui.Modifier import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.rememberNavController @@ -39,6 +44,7 @@ import kotlinx.coroutines.launch import com.sukisu.ultra.Natives import com.sukisu.ultra.ui.component.BottomBar import com.sukisu.ultra.ui.screen.HomePager +import com.sukisu.ultra.ui.screen.KpmScreen import com.sukisu.ultra.ui.screen.ModulePager import com.sukisu.ultra.ui.screen.SettingPager import com.sukisu.ultra.ui.screen.SuperUserPager @@ -120,7 +126,22 @@ val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle fun MainScreen(navController: DestinationsNavigator) { val activity = LocalActivity.current val coroutineScope = rememberCoroutineScope() - val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 }) + + // 检查 KPM 版本是否可用 + val kpmVersion by produceState(initialValue = "") { + value = withContext(Dispatchers.IO) { + try { + getKpmVersion() + } catch (e: Exception) { + "" + } + } + } + + val isKpmAvailable = kpmVersion.isNotEmpty() && !kpmVersion.contains("Error", ignoreCase = true) + val pageCount = if (isKpmAvailable) 5 else 4 + + val pagerState = rememberPagerState(initialPage = 0, pageCount = { pageCount }) val hazeState = remember { HazeState() } val hazeStyle = HazeStyle( backgroundColor = MiuixTheme.colorScheme.background, @@ -148,7 +169,7 @@ fun MainScreen(navController: DestinationsNavigator) { ) { Scaffold( bottomBar = { - BottomBar(hazeState, hazeStyle) + BottomBar(hazeState, hazeStyle, isKpmAvailable) }, ) { innerPadding -> HorizontalPager( @@ -157,11 +178,24 @@ fun MainScreen(navController: DestinationsNavigator) { beyondViewportPageCount = 2, userScrollEnabled = false ) { - when (it) { - 0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding()) - 1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding()) - 2 -> ModulePager(navController, innerPadding.calculateBottomPadding()) - 3 -> SettingPager(navController, innerPadding.calculateBottomPadding()) + when { + isKpmAvailable -> { + when (it) { + 0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding()) + 1 -> KpmScreen(bottomInnerPadding = innerPadding.calculateBottomPadding()) + 2 -> SuperUserPager(navController, innerPadding.calculateBottomPadding()) + 3 -> ModulePager(navController, innerPadding.calculateBottomPadding()) + 4 -> SettingPager(navController, innerPadding.calculateBottomPadding()) + } + } + else -> { + when (it) { + 0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding()) + 1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding()) + 2 -> ModulePager(navController, innerPadding.calculateBottomPadding()) + 3 -> SettingPager(navController, innerPadding.calculateBottomPadding()) + } + } } } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/BottomBar.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/BottomBar.kt index 186d9763..ba4c2d5c 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/component/BottomBar.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/BottomBar.kt @@ -2,6 +2,7 @@ package com.sukisu.ultra.ui.component import androidx.annotation.StringRes import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Cottage import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Security @@ -27,7 +28,8 @@ import top.yukonga.miuix.kmp.basic.NavigationItem @Composable fun BottomBar( hazeState: HazeState, - hazeStyle: HazeStyle + hazeStyle: HazeStyle, + isKpmAvailable: Boolean = false ) { val isManager = Natives.isManager val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable() @@ -37,12 +39,24 @@ fun BottomBar( if (!fullFeatured) return - val item = BottomBarDestination.entries.mapIndexed { index, destination -> + val destinations = if (isKpmAvailable) { + BottomBarDestination.entries + } else { + BottomBarDestination.entries.filter { it != BottomBarDestination.KPM } + } + + val item = destinations.mapIndexed { index, destination -> NavigationItem( label = stringResource(destination.label), icon = destination.icon, ) } + + val bottomBarIndex = if (!isKpmAvailable) { + page.coerceIn(0, item.size - 1) + } else { + page.coerceIn(0, item.size - 1) + } NavigationBar( modifier = Modifier @@ -53,8 +67,10 @@ fun BottomBar( }, color = Color.Transparent, items = item, - selected = page, - onClick = handlePageChange + selected = bottomBarIndex, + onClick = { index -> + handlePageChange(index) + } ) } @@ -63,6 +79,7 @@ enum class BottomBarDestination( val icon: ImageVector, ) { Home(R.string.home, Icons.Rounded.Cottage), + KPM(R.string.kpm_title, Icons.Rounded.Code), SuperUser(R.string.superuser, Icons.Rounded.Security), Module(R.string.module, Icons.Rounded.Extension), Setting(R.string.settings, Icons.Rounded.Settings) diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/KernelFlash.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/KernelFlash.kt new file mode 100644 index 00000000..6b0381c2 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/KernelFlash.kt @@ -0,0 +1,433 @@ +package com.sukisu.ultra.ui.kernelFlash + +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.rounded.Refresh +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.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +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.KeyEventBlocker +import com.sukisu.ultra.ui.util.reboot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.sukisu.ultra.ui.kernelFlash.state.* +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.FloatingActionButton +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.LinearProgressIndicator +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Back +import top.yukonga.miuix.kmp.icon.icons.useful.Save +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme +import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +private object KernelFlashStateHolder { + var currentState: HorizonKernelState? = null + var currentUri: Uri? = null + var currentSlot: String? = null + var currentKpmPatchEnabled: Boolean = false + var currentKpmUndoPatch: Boolean = false + var isFlashing = false +} + +/** + * Kernel刷写界面 + */ +@Destination +@Composable +fun KernelFlashScreen( + navigator: DestinationsNavigator, + kernelUri: Uri, + selectedSlot: String? = null, + kpmPatchEnabled: Boolean = false, + kpmUndoPatch: Boolean = false +) { + val context = LocalContext.current + + val shouldAutoExit = remember { + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.getBoolean("auto_exit_after_flash", false) + } + + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var logText by rememberSaveable { mutableStateOf("") } + var showFloatAction by rememberSaveable { mutableStateOf(false) } + val logContent = rememberSaveable { StringBuilder() } + val horizonKernelState = remember { + if (KernelFlashStateHolder.currentState != null && + KernelFlashStateHolder.currentUri == kernelUri && + KernelFlashStateHolder.currentSlot == selectedSlot && + KernelFlashStateHolder.currentKpmPatchEnabled == kpmPatchEnabled && + KernelFlashStateHolder.currentKpmUndoPatch == kpmUndoPatch) { + KernelFlashStateHolder.currentState!! + } else { + HorizonKernelState().also { + KernelFlashStateHolder.currentState = it + KernelFlashStateHolder.currentUri = kernelUri + KernelFlashStateHolder.currentSlot = selectedSlot + KernelFlashStateHolder.currentKpmPatchEnabled = kpmPatchEnabled + KernelFlashStateHolder.currentKpmUndoPatch = kpmUndoPatch + KernelFlashStateHolder.isFlashing = false + } + } + } + + val flashState by horizonKernelState.state.collectAsState() + + val onFlashComplete = { + showFloatAction = true + KernelFlashStateHolder.isFlashing = false + + // 如果需要自动退出,延迟1.5秒后退出 + if (shouldAutoExit) { + scope.launch { + delay(1500) + val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) + sharedPref.edit { remove("auto_exit_after_flash") } + (context as? ComponentActivity)?.finish() + } + } + } + + // 开始刷写 + LaunchedEffect(Unit) { + if (!KernelFlashStateHolder.isFlashing && !flashState.isCompleted && flashState.error.isEmpty()) { + withContext(Dispatchers.IO) { + KernelFlashStateHolder.isFlashing = true + val worker = HorizonKernelWorker( + context = context, + state = horizonKernelState, + slot = selectedSlot, + kpmPatchEnabled = kpmPatchEnabled, + kpmUndoPatch = kpmUndoPatch + ) + worker.uri = kernelUri + worker.setOnFlashCompleteListener(onFlashComplete) + worker.start() + + // 监听日志更新 + while (flashState.error.isEmpty()) { + if (flashState.logs.isNotEmpty()) { + logText = flashState.logs.joinToString("\n") + logContent.clear() + logContent.append(logText) + } + delay(100) + } + + if (flashState.error.isNotEmpty()) { + logText += "\n${flashState.error}\n" + logContent.append("\n${flashState.error}\n") + KernelFlashStateHolder.isFlashing = false + } + } + } else { + logText = flashState.logs.joinToString("\n") + if (flashState.error.isNotEmpty()) { + logText += "\n${flashState.error}\n" + } else if (flashState.isCompleted) { + logText += "\n${context.getString(R.string.horizon_flash_complete)}\n\n\n" + showFloatAction = true + } + } + } + + val onBack: () -> Unit = { + if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) { + // 清理全局状态 + if (flashState.isCompleted || flashState.error.isNotEmpty()) { + KernelFlashStateHolder.currentState = null + KernelFlashStateHolder.currentUri = null + KernelFlashStateHolder.currentSlot = null + KernelFlashStateHolder.currentKpmPatchEnabled = false + KernelFlashStateHolder.currentKpmUndoPatch = false + KernelFlashStateHolder.isFlashing = false + } + navigator.popBackStack() + } + } + + DisposableEffect(shouldAutoExit) { + onDispose { + if (shouldAutoExit) { + KernelFlashStateHolder.currentState = null + KernelFlashStateHolder.currentUri = null + KernelFlashStateHolder.currentSlot = null + KernelFlashStateHolder.currentKpmPatchEnabled = false + KernelFlashStateHolder.currentKpmUndoPatch = false + KernelFlashStateHolder.isFlashing = false + } + } + } + + BackHandler { + onBack() + } + + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + + Scaffold( + topBar = { + TopBar( + flashState = flashState, + onBack = onBack, + 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_kernel_flash_log_${date}.log" + ) + file.writeText(logContent.toString()) + } + } + ) + }, + floatingActionButton = { + if (showFloatAction) { + FloatingActionButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + reboot() + } + } + }, + modifier = Modifier.padding(bottom = 20.dp, end = 20.dp) + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(id = R.string.reboot) + ) + } + } + }, + popupHost = { } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .scrollEndHaptic(), + ) { + FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + LaunchedEffect(logText) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(16.dp), + text = logText, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun FlashProgressIndicator( + flashState: FlashState, + kpmPatchEnabled: Boolean = false, + kpmUndoPatch: Boolean = false +) { + val progressColor = when { + flashState.error.isNotEmpty() -> colorScheme.primary + flashState.isCompleted -> colorScheme.secondary + else -> colorScheme.primary + } + + val progress = animateFloatAsState( + targetValue = flashState.progress, + label = "FlashProgress" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = when { + flashState.error.isNotEmpty() -> stringResource(R.string.flash_failed) + flashState.isCompleted -> stringResource(R.string.flash_success) + else -> stringResource(R.string.flashing) + }, + fontWeight = FontWeight.Bold, + color = progressColor + ) + + when { + flashState.error.isNotEmpty() -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = colorScheme.primary + ) + } + flashState.isCompleted -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = colorScheme.secondary + ) + } + } + } + + // KPM状态显示 + if (kpmPatchEnabled || kpmUndoPatch) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode) + else stringResource(R.string.kpm_patch_mode), + color = colorScheme.secondary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (flashState.currentStep.isNotEmpty()) { + Text( + text = flashState.currentStep, + color = colorScheme.onSurfaceVariantSummary + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + LinearProgressIndicator( + progress = progress.value, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + ) + + if (flashState.error.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = flashState.error, + color = colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .background( + colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + .padding(8.dp) + ) + } + } + } +} + +@Composable +private fun TopBar( + flashState: FlashState, + onBack: () -> Unit, + onSave: () -> Unit = {} +) { + SmallTopAppBar( + title = stringResource( + when { + flashState.error.isNotEmpty() -> R.string.flash_failed + flashState.isCompleted -> R.string.flash_success + else -> R.string.kernel_flashing + } + ), + navigationIcon = { + IconButton( + modifier = Modifier.padding(start = 16.dp), + onClick = onBack + ) { + Icon( + MiuixIcons.Useful.Back, + contentDescription = null, + tint = colorScheme.onBackground + ) + } + }, + actions = { + IconButton( + modifier = Modifier.padding(end = 16.dp), + onClick = onSave + ) { + Icon( + imageVector = MiuixIcons.Useful.Save, + contentDescription = stringResource(id = R.string.save_log), + tint = colorScheme.onBackground + ) + } + } + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/component/SlotSelectionDialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/component/SlotSelectionDialog.kt new file mode 100644 index 00000000..78bc7131 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/component/SlotSelectionDialog.kt @@ -0,0 +1,218 @@ +package com.sukisu.ultra.ui.kernelFlash.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.R +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme + +/** + * 槽位选择对话框组件 + * 用于Kernel刷写时选择目标槽位 + */ +@Composable +fun SlotSelectionDialog( + show: Boolean, + onDismiss: () -> Unit, + onSlotSelected: (String) -> Unit +) { + var currentSlot by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + var selectedSlot by remember { mutableStateOf(null) } + val showDialog = remember { mutableStateOf(show) } + + LaunchedEffect(show) { + showDialog.value = show + if (show) { + try { + currentSlot = getCurrentSlot() + // 设置默认选择为当前槽位 + selectedSlot = when (currentSlot) { + "a" -> "a" + "b" -> "b" + else -> null + } + errorMessage = null + } catch (e: Exception) { + errorMessage = e.message + currentSlot = null + } + } + } + + SuperDialog( + show = showDialog, + insideMargin = DpSize(0.dp, 0.dp), + onDismissRequest = { + showDialog.value = false + onDismiss() + }, + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) { + // 标题 + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + text = stringResource(id = R.string.select_slot_title), + fontSize = MiuixTheme.textStyles.title4.fontSize, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = colorScheme.onSurface + ) + + // 当前槽位或错误信息 + if (errorMessage != null) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + text = "Error: $errorMessage", + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = colorScheme.primary, + textAlign = TextAlign.Center + ) + } else { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource( + id = R.string.current_slot, + currentSlot ?: "Unknown" + ), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = colorScheme.onSurfaceVariantSummary, + textAlign = TextAlign.Center + ) + } + + // 描述文本 + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource(id = R.string.select_slot_description), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = colorScheme.onSurfaceVariantSummary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 槽位选项 + val slotOptions = listOf( + SlotOption( + slot = "a", + titleText = stringResource(id = R.string.slot_a), + icon = Icons.Filled.SdStorage + ), + SlotOption( + slot = "b", + titleText = stringResource(id = R.string.slot_b), + icon = Icons.Filled.SdStorage + ) + ) + + slotOptions.forEach { option -> + SuperArrow( + title = option.titleText, + leftAction = { + Icon( + imageVector = option.icon, + contentDescription = null, + tint = if (selectedSlot == option.slot) { + colorScheme.primary + } else { + colorScheme.onSurfaceVariantSummary + } + ) + }, + onClick = { + selectedSlot = option.slot + }, + insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 按钮行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + text = stringResource(android.R.string.cancel), + onClick = { + showDialog.value = false + onDismiss() + }, + modifier = Modifier.weight(1f) + ) + TextButton( + text = stringResource(android.R.string.ok), + onClick = { + selectedSlot?.let { onSlotSelected(it) } + showDialog.value = false + onDismiss() + }, + enabled = selectedSlot != null, + modifier = Modifier.weight(1f) + ) + } + } + } + ) +} + +// Data class for slot options +data class SlotOption( + val slot: String, + val titleText: String, + val icon: ImageVector +) + +// Utility function to get current slot +private fun getCurrentSlot(): String? { + return runCommandGetOutput()?.let { + if (it.startsWith("_")) it.substring(1) else it + } +} + +private fun runCommandGetOutput(): String? { + val cmd = "getprop ro.boot.slot_suffix" + return try { + val process = ProcessBuilder("su").start() + process.outputStream.bufferedWriter().use { writer -> + writer.write("$cmd\n") + writer.write("exit\n") + writer.flush() + } + process.inputStream.bufferedReader().use { reader -> + reader.readText().trim() + } + } catch (_: Exception) { + null + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/state/KernelFlashState.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/state/KernelFlashState.kt new file mode 100644 index 00000000..bf73ed23 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/state/KernelFlashState.kt @@ -0,0 +1,524 @@ +package com.sukisu.ultra.ui.kernelFlash.state + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.sukisu.ultra.R +import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil +import com.sukisu.ultra.ui.kernelFlash.util.RemoteToolsDownloader +import com.sukisu.ultra.ui.util.install +import com.sukisu.ultra.ui.util.rootAvailable +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +data class FlashState( + val isFlashing: Boolean = false, + val isCompleted: Boolean = false, + val progress: Float = 0f, + val currentStep: String = "", + val logs: List = emptyList(), + val error: String = "" +) + +class HorizonKernelState { + private val _state = MutableStateFlow(FlashState()) + val state: StateFlow = _state.asStateFlow() + + fun updateProgress(progress: Float) { + _state.update { it.copy(progress = progress) } + } + + fun updateStep(step: String) { + _state.update { it.copy(currentStep = step) } + } + + fun addLog(log: String) { + _state.update { + it.copy(logs = it.logs + log) + } + } + + fun setError(error: String) { + _state.update { it.copy(error = error) } + } + + fun startFlashing() { + _state.update { + it.copy( + isFlashing = true, + isCompleted = false, + progress = 0f, + currentStep = "under preparation...", + logs = emptyList(), + error = "" + ) + } + } + + fun completeFlashing() { + _state.update { it.copy(isCompleted = true, progress = 1f) } + } + + fun reset() { + _state.value = FlashState() + } +} + +class HorizonKernelWorker( + private val context: Context, + private val state: HorizonKernelState, + private val slot: String? = null, + private val kpmPatchEnabled: Boolean = false, + private val kpmUndoPatch: Boolean = false +) : Thread() { + var uri: Uri? = null + private lateinit var filePath: String + private lateinit var binaryPath: String + private lateinit var workDir: String + + private var onFlashComplete: (() -> Unit)? = null + private var originalSlot: String? = null + private var downloaderJob: Job? = null + + fun setOnFlashCompleteListener(listener: () -> Unit) { + onFlashComplete = listener + } + + override fun run() { + state.startFlashing() + state.updateStep(context.getString(R.string.horizon_preparing)) + + filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}" + binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary" + workDir = "${context.filesDir.absolutePath}/work" + + try { + state.updateStep(context.getString(R.string.horizon_cleaning_files)) + state.updateProgress(0.1f) + cleanup() + + if (!rootAvailable()) { + state.setError(context.getString(R.string.root_required)) + return + } + + state.updateStep(context.getString(R.string.horizon_copying_files)) + state.updateProgress(0.2f) + copy() + + if (!File(filePath).exists()) { + state.setError(context.getString(R.string.horizon_copy_failed)) + return + } + + state.updateStep(context.getString(R.string.horizon_extracting_tool)) + state.updateProgress(0.4f) + getBinary() + + // KPM修补 + if (kpmPatchEnabled || kpmUndoPatch) { + state.updateStep(context.getString(R.string.kpm_preparing_tools)) + state.updateProgress(0.5f) + prepareKpmToolsWithDownload() + + state.updateStep( + if (kpmUndoPatch) context.getString(R.string.kpm_undoing_patch) + else context.getString(R.string.kpm_applying_patch) + ) + state.updateProgress(0.55f) + performKpmPatch() + } + + state.updateStep(context.getString(R.string.horizon_patching_script)) + state.updateProgress(0.6f) + patch() + + state.updateStep(context.getString(R.string.horizon_flashing)) + state.updateProgress(0.7f) + + val isAbDevice = isAbDevice() + + if (isAbDevice && slot != null) { + state.updateStep(context.getString(R.string.horizon_getting_original_slot)) + state.updateProgress(0.72f) + originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix") + + state.updateStep(context.getString(R.string.horizon_setting_target_slot)) + state.updateProgress(0.74f) + runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot") + } + + flash() + + if (isAbDevice && !originalSlot.isNullOrEmpty()) { + state.updateStep(context.getString(R.string.horizon_restoring_original_slot)) + state.updateProgress(0.8f) + runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot") + } + + try { + install() + } catch (e: Exception) { + state.updateStep("ksud update skipped: ${e.message}") + } + + state.updateStep(context.getString(R.string.horizon_flash_complete_status)) + state.completeFlashing() + + (context as? Activity)?.runOnUiThread { + onFlashComplete?.invoke() + } + } catch (e: Exception) { + state.setError(e.message ?: context.getString(R.string.horizon_unknown_error)) + + if (isAbDevice() && !originalSlot.isNullOrEmpty()) { + state.updateStep(context.getString(R.string.horizon_restoring_original_slot)) + state.updateProgress(0.8f) + runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot") + } + } finally { + // 取消下载任务并清理 + downloaderJob?.cancel() + cleanupDownloader() + } + } + + private fun prepareKpmToolsWithDownload() { + try { + File(workDir).mkdirs() + val downloader = RemoteToolsDownloader(context, workDir) + + val progressListener = object : RemoteToolsDownloader.DownloadProgressListener { + override fun onProgress(fileName: String, progress: Int, total: Int) { + val percentage = if (total > 0) (progress * 100) / total else 0 + state.addLog("Downloading $fileName: $percentage% ($progress/$total bytes)") + } + + override fun onLog(message: String) { + state.addLog(message) + } + + override fun onError(fileName: String, error: String) { + state.addLog("Warning: $fileName - $error") + } + + override fun onSuccess(fileName: String, isRemote: Boolean) { + val source = if (isRemote) "remote" else "local" + state.addLog("✓ $fileName $source version prepared successfully") + } + } + + val downloadJob = CoroutineScope(Dispatchers.IO).launch { + downloader.downloadToolsAsync(progressListener) + } + + downloaderJob = downloadJob + + runBlocking { + downloadJob.join() + } + + val kptoolsPath = "$workDir/kptools" + val kpimgPath = "$workDir/kpimg" + + if (!File(kptoolsPath).exists()) { + throw IOException("kptools file preparation failed") + } + + if (!File(kpimgPath).exists()) { + throw IOException("kpimg file preparation failed") + } + + runCommand(true, "chmod a+rx $kptoolsPath") + state.addLog("KPM tools preparation completed, starting patch operation") + + } catch (_: CancellationException) { + state.addLog("KPM tools download cancelled") + throw IOException("Tool preparation process interrupted") + } catch (e: Exception) { + state.addLog("KPM tools preparation failed: ${e.message}") + + state.addLog("Attempting to use legacy local file extraction...") + try { + prepareKpmToolsLegacy() + state.addLog("Successfully used local backup files") + } catch (legacyException: Exception) { + state.addLog("Local file extraction also failed: ${legacyException.message}") + throw IOException("Unable to prepare KPM tool files: ${e.message}") + } + } + } + + private fun prepareKpmToolsLegacy() { + File(workDir).mkdirs() + + val kptoolsPath = "$workDir/kptools" + val kpimgPath = "$workDir/kpimg" + + AssetsUtil.exportFiles(context, "kptools", kptoolsPath) + if (!File(kptoolsPath).exists()) { + throw IOException("Local kptools file extraction failed") + } + + AssetsUtil.exportFiles(context, "kpimg", kpimgPath) + if (!File(kpimgPath).exists()) { + throw IOException("Local kpimg file extraction failed") + } + + runCommand(true, "chmod a+rx $kptoolsPath") + } + + private fun cleanupDownloader() { + try { + val downloader = RemoteToolsDownloader(context, workDir) + downloader.cleanup() + } catch (_: Exception) { + } + } + + /** + * 执行KPM修补操作 + */ + private fun performKpmPatch() { + try { + // 创建临时解压目录 + val extractDir = "$workDir/extracted" + File(extractDir).mkdirs() + + // 解压压缩包到临时目录 + val unzipResult = runCommand(true, "cd $extractDir && unzip -o \"$filePath\"") + if (unzipResult != 0) { + throw IOException(context.getString(R.string.kpm_extract_zip_failed)) + } + + // 查找Image文件 + val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f") + if (findImageResult.isBlank()) { + throw IOException(context.getString(R.string.kpm_image_file_not_found)) + } + + val imageFile = findImageResult.lines().first().trim() + val imageDir = File(imageFile).parent + val imageName = File(imageFile).name + + state.addLog(context.getString(R.string.kpm_found_image_file, imageFile)) + + // 复制KPM工具到Image文件所在目录 + runCommand(true, "cp $workDir/kptools $imageDir/") + runCommand(true, "cp $workDir/kpimg $imageDir/") + + // 执行KPM修补命令 + val patchCommand = if (kpmUndoPatch) { + "cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName" + } else { + "cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName" + } + + val patchResult = runCommand(true, patchCommand) + if (patchResult != 0) { + throw IOException( + if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_failed) + else context.getString(R.string.kpm_patch_failed) + ) + } + + state.addLog( + if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_success) + else context.getString(R.string.kpm_patch_success) + ) + + // 清理KPM工具文件 + runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage") + + // 重新打包ZIP文件 + val originalFileName = File(filePath).name + val patchedFilePath = "$workDir/patched_$originalFileName" + + repackZipFolder(extractDir, patchedFilePath) + + // 替换原始文件 + runCommand(true, "mv \"$patchedFilePath\" \"$filePath\"") + + state.addLog(context.getString(R.string.kpm_file_repacked)) + + } catch (e: Exception) { + state.addLog(context.getString(R.string.kpm_patch_operation_failed, e.message)) + throw e + } finally { + // 清理临时文件 + runCommand(true, "rm -rf $workDir") + } + } + + private fun repackZipFolder(sourceDir: String, zipFilePath: String) { + try { + val buffer = ByteArray(1024) + val sourceFolder = File(sourceDir) + + FileOutputStream(zipFilePath).use { fos -> + ZipOutputStream(fos).use { zos -> + sourceFolder.walkTopDown().forEach { file -> + if (file.isFile) { + val relativePath = file.relativeTo(sourceFolder).path + val zipEntry = ZipEntry(relativePath) + zos.putNextEntry(zipEntry) + + file.inputStream().use { fis -> + var length: Int + while (fis.read(buffer).also { length = it } > 0) { + zos.write(buffer, 0, length) + } + } + + zos.closeEntry() + } + } + } + } + } catch (e: Exception) { + throw IOException("Failed to create zip file: ${e.message}", e) + } + } + + // 检查设备是否为AB分区设备 + private fun isAbDevice(): Boolean { + val abUpdate = runCommandGetOutput("getprop ro.build.ab_update") + if (!abUpdate.toBoolean()) return false + + val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix") + return slotSuffix.isNotEmpty() + } + + private fun cleanup() { + runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete") + runCommand(false, "rm -rf $workDir") + } + + private fun copy() { + uri?.let { safeUri -> + context.contentResolver.openInputStream(safeUri)?.use { input -> + FileOutputStream(File(filePath)).use { output -> + input.copyTo(output) + } + } + } + } + + private fun getBinary() { + runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}") + if (!File(binaryPath).exists()) { + throw IOException("Failed to extract update-binary") + } + } + + @SuppressLint("StringFormatInvalid") + private fun patch() { + val kernelVersion = runCommandGetOutput("cat /proc/version") + val versionRegex = """\d+\.\d+\.\d+""".toRegex() + val version = kernelVersion.let { versionRegex.find(it) }?.value ?: "" + val toolName = if (version.isNotEmpty()) { + val parts = version.split('.') + if (parts.size >= 2) { + val major = parts[0].toIntOrNull() ?: 0 + val minor = parts[1].toIntOrNull() ?: 0 + if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+" + } else { + "5_15+" + } + } else { + "5_15+" + } + val toolPath = "${context.filesDir.absolutePath}/mkbootfs" + AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath) + state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}") + runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath") + } + + private fun flash() { + val process = ProcessBuilder("su") + .redirectErrorStream(true) + .start() + + try { + process.outputStream.bufferedWriter().use { writer -> + writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n") + + // 写入槽位信息到临时文件 + slot?.let { selectedSlot -> + writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n") + } + + // 构建刷写命令 + val flashCommand = buildString { + append("sh $binaryPath 3 1 \"$filePath\"") + if (slot != null) { + append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"") + } + append(" && touch ${context.filesDir.absolutePath}/done\n") + } + + writer.write(flashCommand) + writer.write("exit\n") + writer.flush() + } + + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + if (line.startsWith("ui_print")) { + val logMessage = line.removePrefix("ui_print").trim() + state.addLog(logMessage) + + when { + logMessage.contains("extracting", ignoreCase = true) -> { + state.updateProgress(0.75f) + } + logMessage.contains("installing", ignoreCase = true) -> { + state.updateProgress(0.85f) + } + logMessage.contains("complete", ignoreCase = true) -> { + state.updateProgress(0.95f) + } + } + } + } + } + } finally { + process.destroy() + } + + if (!File("${context.filesDir.absolutePath}/done").exists()) { + throw IOException(context.getString(R.string.flash_failed_message)) + } + } + + private fun runCommand(su: Boolean, cmd: String): Int { + val shell = if (su) "su" else "sh" + val process = Runtime.getRuntime().exec(arrayOf(shell, "-c", cmd)) + + return try { + process.waitFor() + } finally { + process.destroy() + } + } + + private fun runCommandGetOutput(cmd: String): String { + return Shell.cmd(cmd).exec().out.joinToString("\n").trim() + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/AssetsUtil.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/AssetsUtil.kt new file mode 100644 index 00000000..90fa9781 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/AssetsUtil.kt @@ -0,0 +1,26 @@ +package com.sukisu.ultra.ui.kernelFlash.util + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object AssetsUtil { + @Throws(IOException::class) + fun exportFiles(context: Context, src: String, out: String) { + val fileNames = context.assets.list(src) + if (fileNames?.isNotEmpty() == true) { + val file = File(out) + file.mkdirs() + fileNames.forEach { fileName -> + exportFiles(context, "$src/$fileName", "$out/$fileName") + } + } else { + context.assets.open(src).use { inputStream -> + FileOutputStream(File(out)).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/RemoteToolsDownloader.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/RemoteToolsDownloader.kt new file mode 100644 index 00000000..b92656b4 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/RemoteToolsDownloader.kt @@ -0,0 +1,364 @@ +package com.sukisu.ultra.ui.kernelFlash.util + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import java.util.concurrent.TimeUnit + +class RemoteToolsDownloader( + private val context: Context, + private val workDir: String +) { + companion object { + private const val TAG = "RemoteToolsDownloader" + + // 远程下载URL配置 + private const val KPTOOLS_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kptools" + private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg" + + // 网络超时配置(毫秒) + private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时 + private const val READ_TIMEOUT = 30000 // 30秒读取超时 + + // 最大重试次数 + private const val MAX_RETRY_COUNT = 3 + + // 文件校验相关 + private const val MIN_FILE_SIZE = 1024 + } + + interface DownloadProgressListener { + fun onProgress(fileName: String, progress: Int, total: Int) + fun onLog(message: String) + fun onError(fileName: String, error: String) + fun onSuccess(fileName: String, isRemote: Boolean) + } + + data class DownloadResult( + val success: Boolean, + val isRemoteSource: Boolean, + val errorMessage: String? = null + ) + + + suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map = withContext(Dispatchers.IO) { + val results = mutableMapOf() + + listener?.onLog("Starting to prepare KPM tool files...") + + try { + // 确保工作目录存在 + File(workDir).mkdirs() + + // 并行下载两个工具文件 + val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) } + val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) } + + // 等待所有下载完成 + results["kptools"] = kptoolsDeferred.await() + results["kpimg"] = kpimgDeferred.await() + + // 检查kptools执行权限 + val kptoolsFile = File(workDir, "kptools") + if (kptoolsFile.exists()) { + setExecutablePermission(kptoolsFile.absolutePath) + listener?.onLog("Set kptools execution permission") + } + + val successCount = results.values.count { it.success } + val remoteCount = results.values.count { it.success && it.isRemoteSource } + + listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount") + + } catch (e: Exception) { + Log.e(TAG, "Exception occurred while downloading tools", e) + listener?.onLog("Exception occurred during tool download: ${e.message}") + + if (!results.containsKey("kptools")) { + results["kptools"] = downloadSingleTool("kptools", null, listener) + } + if (!results.containsKey("kpimg")) { + results["kpimg"] = downloadSingleTool("kpimg", null, listener) + } + } + + results.toMap() + } + + private suspend fun downloadSingleTool( + fileName: String, + remoteUrl: String?, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + val targetFile = File(workDir, fileName) + + if (remoteUrl == null) { + return@withContext useLocalVersion(fileName, targetFile, listener) + } + + // 尝试从远程下载 + listener?.onLog("Downloading $fileName from remote repository...") + + var lastError = "" + + // 重试机制 + repeat(MAX_RETRY_COUNT) { attempt -> + try { + val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener) + if (result.success) { + listener?.onSuccess(fileName, true) + return@withContext result + } + lastError = result.errorMessage ?: "Unknown error" + + } catch (e: Exception) { + lastError = e.message ?: "Network exception" + Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e) + + if (attempt < MAX_RETRY_COUNT - 1) { + listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...") + delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L)) + } + } + } + + // 所有重试都失败,回退到本地版本 + listener?.onError(fileName, "Remote download failed: $lastError") + listener?.onLog("$fileName remote download failed, falling back to local version...") + + useLocalVersion(fileName, targetFile, listener) + } + + private suspend fun downloadFromRemote( + fileName: String, + remoteUrl: String, + targetFile: File, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + var connection: HttpURLConnection? = null + + try { + val url = URL(remoteUrl) + connection = url.openConnection() as HttpURLConnection + + // 设置连接参数 + connection.apply { + connectTimeout = CONNECTION_TIMEOUT + readTimeout = READ_TIMEOUT + requestMethod = "GET" + setRequestProperty("User-Agent", "SukiSU-KPM-Downloader/1.0") + setRequestProperty("Accept", "*/*") + setRequestProperty("Connection", "close") + } + + // 建立连接 + connection.connect() + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + return@withContext DownloadResult( + false, + isRemoteSource = false, + errorMessage = "HTTP error code: $responseCode" + ) + } + + val fileLength = connection.contentLength + Log.d(TAG, "$fileName remote file size: $fileLength bytes") + + // 创建临时文件 + val tempFile = File(targetFile.absolutePath + ".tmp") + + // 下载文件 + connection.inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var totalBytes = 0 + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + // 检查协程是否被取消 + ensureActive() + + output.write(buffer, 0, bytesRead) + totalBytes += bytesRead + + // 更新下载进度 + if (fileLength > 0) { + listener?.onProgress(fileName, totalBytes, fileLength) + } + } + + output.flush() + } + } + + // 验证下载的文件 + if (!validateDownloadedFile(tempFile, fileName)) { + tempFile.delete() + return@withContext DownloadResult( + success = false, + isRemoteSource = false, + errorMessage = "File verification failed" + ) + } + + // 移动临时文件到目标位置 + if (targetFile.exists()) { + targetFile.delete() + } + + if (!tempFile.renameTo(targetFile)) { + tempFile.delete() + return@withContext DownloadResult( + false, + isRemoteSource = false, + errorMessage = "Failed to move file" + ) + } + + Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes") + listener?.onLog("$fileName remote download successful") + + DownloadResult(true, isRemoteSource = true) + + } catch (e: SocketTimeoutException) { + Log.w(TAG, "$fileName download timeout", e) + DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout") + } catch (e: IOException) { + Log.w(TAG, "$fileName network IO exception", e) + DownloadResult(false, + isRemoteSource = false, + errorMessage = "Network connection exception: ${e.message}" + ) + } catch (e: Exception) { + Log.e(TAG, "$fileName exception occurred during download", e) + DownloadResult(false, + isRemoteSource = false, + errorMessage = "Download exception: ${e.message}" + ) + } finally { + connection?.disconnect() + } + } + + private suspend fun useLocalVersion( + fileName: String, + targetFile: File, + listener: DownloadProgressListener? + ): DownloadResult = withContext(Dispatchers.IO) { + + try { + AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath) + + if (!targetFile.exists()) { + val errorMsg = "Local $fileName file extraction failed" + listener?.onError(fileName, errorMsg) + return@withContext DownloadResult(false, + isRemoteSource = false, + errorMessage = errorMsg + ) + } + + if (!validateDownloadedFile(targetFile, fileName)) { + val errorMsg = "Local $fileName file verification failed" + listener?.onError(fileName, errorMsg) + return@withContext DownloadResult( + success = false, + isRemoteSource = false, + errorMessage = errorMsg + ) + } + + Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes") + listener?.onLog("$fileName local version loaded successfully") + listener?.onSuccess(fileName, false) + + DownloadResult(true, isRemoteSource = false) + + } catch (e: Exception) { + Log.e(TAG, "$fileName local version loading failed", e) + val errorMsg = "Local version loading failed: ${e.message}" + listener?.onError(fileName, errorMsg) + DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg) + } + } + + private fun validateDownloadedFile(file: File, fileName: String): Boolean { + if (!file.exists()) { + Log.w(TAG, "$fileName file does not exist") + return false + } + + val fileSize = file.length() + if (fileSize < MIN_FILE_SIZE) { + Log.w(TAG, "$fileName file is too small: $fileSize bytes") + return false + } + + try { + file.inputStream().use { input -> + val header = ByteArray(4) + val bytesRead = input.read(header) + + if (bytesRead < 4) { + Log.w(TAG, "$fileName file header read incomplete") + return false + } + + val isELF = header[0] == 0x7F.toByte() && + header[1] == 'E'.code.toByte() && + header[2] == 'L'.code.toByte() && + header[3] == 'F'.code.toByte() + + if (fileName == "kptools" && !isELF) { + Log.w(TAG, "kptools file format is invalid, not ELF format") + return false + } + + Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF") + return true + } + } catch (e: Exception) { + Log.w(TAG, "$fileName file verification exception", e) + return false + } + } + + private fun setExecutablePermission(filePath: String) { + try { + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath")) + process.waitFor() + Log.d(TAG, "Set execution permission for $filePath") + } catch (e: Exception) { + Log.w(TAG, "Failed to set execution permission: $filePath", e) + try { + File(filePath).setExecutable(true, false) + } catch (ex: Exception) { + Log.w(TAG, "Java method to set permissions also failed", ex) + } + } + } + + + fun cleanup() { + try { + File(workDir).listFiles()?.forEach { file -> + if (file.name.endsWith(".tmp")) { + file.delete() + Log.d(TAG, "Cleaned temporary file: ${file.name}") + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to clean temporary files", e) + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt index 7fdee6e0..22f04b42 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt @@ -117,10 +117,7 @@ fun HomePager( TopBar( kernelVersion = kernelVersion, onInstallClick = { - navigator.navigate(InstallScreenDestination) { - popUpTo(InstallScreenDestination) { - inclusive = true - } + navigator.navigate(InstallScreenDestination()) { launchSingleTop = true } }, @@ -171,7 +168,7 @@ fun HomePager( StatusCard( kernelVersion, ksuVersion, lkmMode, onClickInstall = { - navigator.navigate(InstallScreenDestination) { + navigator.navigate(InstallScreenDestination()) { launchSingleTop = true } }, diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt index b0f26561..4966c0c2 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -46,11 +47,18 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.material.icons.filled.Security +import androidx.core.net.toUri import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeStyle @@ -58,9 +66,11 @@ import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import com.sukisu.ultra.R +import com.sukisu.ultra.getKernelVersion import com.sukisu.ultra.ui.component.ChooseKmiDialog import com.sukisu.ultra.ui.component.SuperDropdown import com.sukisu.ultra.ui.component.rememberConfirmDialog +import com.sukisu.ultra.ui.kernelFlash.component.SlotSelectionDialog import com.sukisu.ultra.ui.util.LkmSelection import com.sukisu.ultra.ui.util.getAvailablePartitions import com.sukisu.ultra.ui.util.getCurrentKmi @@ -84,6 +94,11 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.icons.useful.Back import top.yukonga.miuix.kmp.icon.icons.useful.Edit import top.yukonga.miuix.kmp.icon.icons.useful.Move +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.basic.TextButton +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.unit.DpSize import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.utils.getWindowSize @@ -94,9 +109,18 @@ import top.yukonga.miuix.kmp.utils.scrollEndHaptic * @author weishu * @date 2024/3/12. */ +enum class KpmPatchOption { + FOLLOW_KERNEL, + PATCH_KPM, + UNDO_PATCH_KPM +} + @Composable @Destination -fun InstallScreen(navigator: DestinationsNavigator) { +fun InstallScreen( + navigator: DestinationsNavigator, + preselectedKernelUri: String? = null +) { val context = LocalContext.current var installMethod by remember { mutableStateOf(null) @@ -106,26 +130,110 @@ fun InstallScreen(navigator: DestinationsNavigator) { mutableStateOf(LkmSelection.KmiNone) } + var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) } + var showSlotSelectionDialog by remember { mutableStateOf(false) } + var showKpmPatchDialog by remember { mutableStateOf(false) } + var tempKernelUri by remember { mutableStateOf(null) } + + val kernelVersion = getKernelVersion() + val isGKI = kernelVersion.isGKI() + val isAbDevice = produceState(initialValue = false) { + value = isAbDevice() + }.value + var partitionSelectionIndex by remember { mutableIntStateOf(0) } var partitionsState by remember { mutableStateOf>(emptyList()) } var hasCustomSelected by remember { mutableStateOf(false) } + val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary) + + // 处理预选的内核文件 + LaunchedEffect(preselectedKernelUri) { + preselectedKernelUri?.let { uriString -> + try { + val preselectedUri = uriString.toUri() + val horizonMethod = InstallMethod.HorizonKernel( + uri = preselectedUri, + summary = horizonKernelSummary + ) + installMethod = horizonMethod + tempKernelUri = preselectedUri + if (isAbDevice) { + showSlotSelectionDialog = true + } else { + showKpmPatchDialog = true + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } val onInstall = { installMethod?.let { method -> - val isOta = method is InstallMethod.DirectInstallToInactiveSlot - val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex) - val flashIt = FlashIt.FlashBoot( - boot = if (method is InstallMethod.SelectFile) method.uri else null, - lkm = lkmSelection, - ota = isOta, - partition = partitionSelection - ) - navigator.navigate(FlashScreenDestination(flashIt)) { - launchSingleTop = true + when (method) { + is InstallMethod.HorizonKernel -> { + method.uri?.let { uri -> + navigator.navigate( + KernelFlashScreenDestination( + kernelUri = uri, + selectedSlot = method.slot, + kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM, + kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM + ) + ) { + launchSingleTop = true + } + } + } + else -> { + val isOta = method is InstallMethod.DirectInstallToInactiveSlot + val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex) + val flashIt = FlashIt.FlashBoot( + boot = if (method is InstallMethod.SelectFile) method.uri else null, + lkm = lkmSelection, + ota = isOta, + partition = partitionSelection + ) + navigator.navigate(FlashScreenDestination(flashIt)) { + launchSingleTop = true + } + } } } } + // 槽位选择对话框 + if (showSlotSelectionDialog && isAbDevice) { + SlotSelectionDialog( + show = true, + onDismiss = { showSlotSelectionDialog = false }, + onSlotSelected = { slot -> + showSlotSelectionDialog = false + val horizonMethod = InstallMethod.HorizonKernel( + uri = tempKernelUri, + slot = slot, + summary = horizonKernelSummary + ) + installMethod = horizonMethod + // 槽位选择后,显示 KPM 补丁选择对话框 + showKpmPatchDialog = true + } + ) + } + + // KPM补丁选择对话框 + if (showKpmPatchDialog) { + KpmPatchSelectionDialog( + show = true, + currentOption = kpmPatchOption, + onDismiss = { showKpmPatchDialog = false }, + onOptionSelected = { option -> + kpmPatchOption = option + showKpmPatchDialog = false + } + ) + } + val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() } val showChooseKmiDialog = rememberSaveable { mutableStateOf(false) } @@ -137,7 +245,7 @@ fun InstallScreen(navigator: DestinationsNavigator) { } val onClickNext = { - if (lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) { + if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank() && installMethod !is InstallMethod.HorizonKernel) { // no lkm file selected and cannot get current kmi showChooseKmiDialog.value = true chooseKmiDialog @@ -174,8 +282,8 @@ fun InstallScreen(navigator: DestinationsNavigator) { val scrollBehavior = MiuixScrollBehavior() val hazeState = remember { HazeState() } val hazeStyle = HazeStyle( - backgroundColor = MiuixTheme.colorScheme.background, - tint = HazeTint(MiuixTheme.colorScheme.background.copy(0.8f)) + backgroundColor = colorScheme.background, + tint = HazeTint(colorScheme.background.copy(0.8f)) ) Scaffold( @@ -207,9 +315,22 @@ fun InstallScreen(navigator: DestinationsNavigator) { modifier = Modifier .fillMaxWidth(), ) { - SelectInstallMethod { method -> - installMethod = method - } + SelectInstallMethod( + onSelected = { method -> + if (method is InstallMethod.HorizonKernel && method.uri != null) { + if (isAbDevice) { + tempKernelUri = method.uri + showSlotSelectionDialog = true + } else { + installMethod = method + showKpmPatchDialog = true + } + } else { + installMethod = method + } + }, + isAbDevice = isAbDevice + ) } AnimatedVisibility( visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot, @@ -256,29 +377,89 @@ fun InstallScreen(navigator: DestinationsNavigator) { ) } } - Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - SuperArrow( - title = stringResource(id = R.string.install_upload_lkm_file), - summary = (lkmSelection as? LkmSelection.LkmUri)?.let { - stringResource( - id = R.string.selected_lkm, - it.uri.lastPathSegment ?: "(file)" - ) - }, - onClick = onLkmUpload, - leftAction = { - Icon( - MiuixIcons.Useful.Move, - tint = colorScheme.onSurface, - modifier = Modifier.padding(end = 16.dp), - contentDescription = null + // LKM 上传选项(仅 GKI) + if (isGKI) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + SuperArrow( + title = stringResource(id = R.string.install_upload_lkm_file), + summary = (lkmSelection as? LkmSelection.LkmUri)?.let { + stringResource( + id = R.string.selected_lkm, + it.uri.lastPathSegment ?: "(file)" + ) + }, + onClick = onLkmUpload, + leftAction = { + Icon( + MiuixIcons.Useful.Move, + tint = colorScheme.onSurface, + modifier = Modifier.padding(end = 16.dp), + contentDescription = null + ) + } + ) + } + } + + // AnyKernel3 相关信息显示 + (installMethod as? InstallMethod.HorizonKernel)?.let { method -> + if (method.slot != null) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + SuperArrow( + title = stringResource( + id = R.string.selected_slot, + if (method.slot == "a") stringResource(id = R.string.slot_a) + else stringResource(id = R.string.slot_b) + ), + onClick = {}, + leftAction = { + Icon( + Icons.Filled.SdStorage, + tint = colorScheme.onSurface, + modifier = Modifier.padding(end = 16.dp), + contentDescription = null + ) + } ) } - ) + } + + // KPM 状态显示 + if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + SuperArrow( + title = when (kpmPatchOption) { + KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled) + KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled) + else -> "" + }, + onClick = {}, + leftAction = { + Icon( + Icons.Filled.Security, + tint = if (kpmPatchOption == KpmPatchOption.PATCH_KPM) + colorScheme.primary + else + colorScheme.secondary, + modifier = Modifier.padding(end = 16.dp), + contentDescription = null + ) + } + ) + } + } } Button( modifier = Modifier @@ -322,19 +503,27 @@ sealed class InstallMethod { get() = R.string.install_inactive_slot } + data class HorizonKernel( + val uri: Uri? = null, + val slot: String? = null, + @get:StringRes override val label: Int = R.string.horizon_kernel, + override val summary: String? = null + ) : InstallMethod() + abstract val label: Int open val summary: String? = null } @Composable -private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { +private fun SelectInstallMethod( + onSelected: (InstallMethod) -> Unit = {}, + isAbDevice: Boolean = false +) { val rootAvailable = rootAvailable() - val isAbDevice = produceState(initialValue = false) { - value = isAbDevice() - }.value val defaultPartitionName = produceState(initialValue = "boot") { value = getDefaultPartition() }.value + val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary) val selectFileTip = stringResource( id = R.string.select_file_tip, defaultPartitionName ) @@ -345,17 +534,26 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { if (isAbDevice) { radioOptions.add(InstallMethod.DirectInstallToInactiveSlot) } + radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary)) } var selectedOption by remember { mutableStateOf(null) } + var currentSelectingMethod by remember { mutableStateOf(null) } + val selectImageLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) { it.data?.data?.let { uri -> - val option = InstallMethod.SelectFile(uri, summary = selectFileTip) - selectedOption = option - onSelected(option) + val option = when (currentSelectingMethod) { + is InstallMethod.SelectFile -> InstallMethod.SelectFile(uri, summary = selectFileTip) + is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(uri, summary = horizonKernelSummary) + else -> null + } + option?.let { opt -> + selectedOption = opt + onSelected(opt) + } } } } @@ -370,11 +568,12 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { val dialogContent = stringResource(id = R.string.install_inactive_slot_warning) val onClick = { option: InstallMethod -> - + currentSelectingMethod = option when (option) { - is InstallMethod.SelectFile -> { + is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> { selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { - type = "application/octet-stream" + type = "application/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream", "application/zip")) }) } @@ -450,6 +649,121 @@ private fun TopBar( ) } +@Composable +private fun KpmPatchSelectionDialog( + show: Boolean, + currentOption: KpmPatchOption, + onDismiss: () -> Unit, + onOptionSelected: (KpmPatchOption) -> Unit +) { + var selectedOption by remember { mutableStateOf(currentOption) } + val showDialog = remember { mutableStateOf(show) } + + LaunchedEffect(show) { + showDialog.value = show + if (show) { + selectedOption = currentOption + } + } + + SuperDialog( + show = showDialog, + insideMargin = DpSize(0.dp, 0.dp), + onDismissRequest = { + showDialog.value = false + onDismiss() + }, + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) { + // 标题 + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + text = stringResource(id = R.string.kpm_patch_options), + fontSize = MiuixTheme.textStyles.title4.fontSize, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = colorScheme.onSurface + ) + + // 描述 + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource(id = R.string.kpm_patch_description), + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = colorScheme.onSurfaceVariantSummary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 选项列表 + val options = listOf( + KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file), + KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch), + KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch) + ) + + options.forEach { (option, title) -> + SuperArrow( + title = title, + onClick = { + selectedOption = option + }, + leftAction = { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = null, + tint = if (selectedOption == option) { + colorScheme.primary + } else { + colorScheme.onSurfaceVariantSummary + } + ) + }, + insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 按钮行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + text = stringResource(android.R.string.cancel), + onClick = { + showDialog.value = false + onDismiss() + }, + modifier = Modifier.weight(1f) + ) + TextButton( + text = stringResource(android.R.string.ok), + onClick = { + onOptionSelected(selectedOption) + showDialog.value = false + onDismiss() + }, + modifier = Modifier.weight(1f) + ) + } + } + } + ) +} + private fun isKoFile(context: Context, uri: Uri): Boolean { val seg = uri.lastPathSegment ?: "" if (seg.endsWith(".ko", ignoreCase = true)) return true diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt new file mode 100644 index 00000000..918f644b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt @@ -0,0 +1,1032 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.* +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CoroutineScope +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.foundation.isSystemInDarkTheme +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.sukisu.ultra.ui.component.* +import com.sukisu.ultra.ui.viewmodel.KpmViewModel +import com.sukisu.ultra.ui.util.* +import java.io.File +import androidx.core.content.edit +import com.sukisu.ultra.R +import java.io.FileInputStream +import java.net.* +import android.app.Activity +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.LayoutDirection +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeSource +import top.yukonga.miuix.kmp.basic.* +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme +import top.yukonga.miuix.kmp.utils.getWindowSize +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.scrollEndHaptic + +/** + * KPM 管理界面 + * 以下内核模块功能由KernelPatch开发,经过修改后加入SukiSU Ultra的内核模块功能 + * 开发者:ShirkNeko, Liaokong + */ +@Destination +@Composable +fun KpmScreen( + viewModel: KpmViewModel = viewModel(), + bottomInnerPadding: Dp = 0.dp +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val confirmDialog = rememberConfirmDialog() + + context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) + + val listState = rememberLazyListState() + var fabVisible by remember { mutableStateOf(true) } + var scrollDistance by remember { mutableFloatStateOf(0f) } + + val searchStatus by viewModel.searchStatus + val scrollBehavior = MiuixScrollBehavior() + val dynamicTopPadding by remember { + derivedStateOf { 12.dp * (1f - scrollBehavior.state.collapsedFraction) } + } + + val moduleConfirmContentMap = viewModel.moduleList.associate { module -> + val moduleFileName = module.id + module.id to stringResource(R.string.confirm_uninstall_content, moduleFileName) + } + val hazeState = remember { HazeState() } + val hazeStyle = HazeStyle( + backgroundColor = colorScheme.background, + tint = HazeTint(colorScheme.background.copy(0.8f)) + ) + + LaunchedEffect(searchStatus.searchText) { + viewModel.updateSearchText(searchStatus.searchText) + } + + + val kpmInstallSuccess = stringResource(R.string.kpm_install_success) + val kpmInstallFailed = stringResource(R.string.kpm_install_failed) + val cancel = stringResource(R.string.cancel) + val uninstall = stringResource(R.string.uninstall) + val failedToCheckModuleFile = stringResource(R.string.snackbar_failed_to_check_module_file) + val kpmUninstallSuccess = stringResource(R.string.kpm_uninstall_success) + val kpmUninstallFailed = stringResource(R.string.kpm_uninstall_failed) + val kpmInstallMode = stringResource(R.string.kpm_install_mode) + val kpmInstallModeLoad = stringResource(R.string.kpm_install_mode_load) + val kpmInstallModeEmbed = stringResource(R.string.kpm_install_mode_embed) + val invalidFileTypeMessage = stringResource(R.string.invalid_file_type) + val confirmTitle = stringResource(R.string.confirm_uninstall_title_with_filename) + + val showToast: suspend (String) -> Unit = { msg -> + scope.launch(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + + var tempFileForInstall by remember { mutableStateOf(null) } + var showInstallModeDialog by remember { mutableStateOf(false) } + val showInstallDialogState = remember { mutableStateOf(false) } + var moduleName by remember { mutableStateOf(null) } + + LaunchedEffect(tempFileForInstall) { + tempFileForInstall?.let { tempFile -> + try { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep 'name='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + if (result.isSuccess) { + for (line in result.out) { + if (line.startsWith("name=")) { + moduleName = line.substringAfter("name=").trim() + break + } + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module name: ${e.message}", e) + } + } + } + + LaunchedEffect(showInstallModeDialog) { + showInstallDialogState.value = showInstallModeDialog + } + + if (showInstallModeDialog) { + SuperDialog( + show = showInstallDialogState, + title = kpmInstallMode, + onDismissRequest = { + showInstallDialogState.value = false + showInstallModeDialog = false + tempFileForInstall?.delete() + tempFileForInstall = null + moduleName = null + }, + content = { + Column { + moduleName?.let { + Text( + text = stringResource(R.string.kpm_install_mode_description, it), + color = colorScheme.onBackground + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + scope.launch { + showInstallDialogState.value = false + showInstallModeDialog = false + tempFileForInstall?.let { tempFile -> + handleModuleInstall( + tempFile = tempFile, + isEmbed = false, + viewModel = viewModel, + showToast = showToast, + kpmInstallSuccess = kpmInstallSuccess, + kpmInstallFailed = kpmInstallFailed + ) + } + tempFileForInstall = null + moduleName = null + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 4.dp) + ) + Text(kpmInstallModeLoad) + } + + Button( + onClick = { + scope.launch { + showInstallDialogState.value = false + showInstallModeDialog = false + tempFileForInstall?.let { tempFile -> + handleModuleInstall( + tempFile = tempFile, + isEmbed = true, + viewModel = viewModel, + showToast = showToast, + kpmInstallSuccess = kpmInstallSuccess, + kpmInstallFailed = kpmInstallFailed + ) + } + tempFileForInstall = null + moduleName = null + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Inventory, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 4.dp) + ) + Text(kpmInstallModeEmbed) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + text = cancel, + onClick = { + showInstallDialogState.value = false + showInstallModeDialog = false + tempFileForInstall?.delete() + tempFileForInstall = null + moduleName = null + }, + modifier = Modifier.weight(1f) + ) + } + } + } + ) + } + + val selectPatchLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + + val uri = result.data?.data ?: return@rememberLauncherForActivityResult + + scope.launch { + val fileName = uri.lastPathSegment ?: "unknown.kpm" + val encodedFileName = URLEncoder.encode(fileName, "UTF-8") + val tempFile = File(context.cacheDir, encodedFileName) + + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val mimeType = context.contentResolver.getType(uri) + val isCorrectMimeType = mimeType == null || mimeType.contains("application/octet-stream") + + if (!isCorrectMimeType) { + var shouldShowToast = true + try { + val matchCount = checkStringsCommand(tempFile) + val isElf = isElfFile(tempFile) + + if (matchCount >= 1 || isElf) { + shouldShowToast = false + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to execute checks: ${e.message}", e) + } + if (shouldShowToast) { + showToast(invalidFileTypeMessage) + } + tempFile.delete() + return@launch + } + tempFileForInstall = tempFile + showInstallModeDialog = true + } + } + + LaunchedEffect(Unit) { + while(true) { + viewModel.fetchModuleList() + delay(5000) + } + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val isScrolledToEnd = + (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1 + && (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.size + ?: 0) < listState.layoutInfo.viewportEndOffset) + val delta = available.y + if (!isScrolledToEnd) { + scrollDistance += delta + if (scrollDistance < -50f) { + if (fabVisible) fabVisible = false + scrollDistance = 0f + } else if (scrollDistance > 50f) { + if (!fabVisible) fabVisible = true + scrollDistance = 0f + } + } + return Offset.Zero + } + } + } + val offsetHeight by animateDpAsState( + targetValue = if (fabVisible) 0.dp else 180.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + animationSpec = tween(durationMillis = 350) + ) + + Scaffold( + topBar = { + searchStatus.TopAppBarAnim(hazeState = hazeState, hazeStyle = hazeStyle) { + TopAppBar( + color = Color.Transparent, + title = stringResource(R.string.kpm_title), + actions = { + IconButton( + onClick = { viewModel.fetchModuleList() } + ) { + Icon( + imageVector = MiuixIcons.Useful.Refresh, + contentDescription = stringResource(R.string.refresh), + tint = colorScheme.onBackground + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + }, + floatingActionButton = { + AnimatedVisibility(visible = fabVisible) { + FloatingActionButton( + modifier = Modifier + .offset(y = offsetHeight) + .padding(bottom = bottomInnerPadding + 20.dp, end = 20.dp) + .border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape), + shadowElevation = 0.dp, + onClick = { + selectPatchLauncher.launch( + Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/octet-stream" + } + ) + }, + content = { + Icon( + painter = painterResource(id = R.drawable.package_import), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp) + ) + } + ) + } + }, + popupHost = { + searchStatus.SearchPager( + defaultResult = {}, + searchBarTopPadding = dynamicTopPadding, + ) { + item { + Spacer(Modifier.height(6.dp)) + } + items(viewModel.moduleList) { module -> + KpmModuleItem( + module = module, + onUninstall = { + scope.launch { + val confirmContent = moduleConfirmContentMap[module.id] ?: "" + handleModuleUninstall( + module = module, + viewModel = viewModel, + showToast = showToast, + kpmUninstallSuccess = kpmUninstallSuccess, + kpmUninstallFailed = kpmUninstallFailed, + failedToCheckModuleFile = failedToCheckModuleFile, + uninstall = uninstall, + cancel = cancel, + confirmDialog = confirmDialog, + confirmTitle = confirmTitle, + confirmContent = confirmContent + ) + } + }, + onControl = { + viewModel.loadModuleDetail(module.id) + } + ) + } + item { + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + Spacer(Modifier.height(maxOf(bottomInnerPadding, imeBottomPadding))) + } + } + }, + contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal) + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + when { + viewModel.moduleList.isEmpty() && searchStatus.searchText.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + bottom = bottomInnerPadding + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.Code, + contentDescription = null, + tint = colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier + .size(96.dp) + .padding(bottom = 16.dp) + ) + Text( + stringResource(R.string.kpm_empty), + textAlign = TextAlign.Center, + color = colorScheme.onBackground + ) + } + } + } + else -> { + searchStatus.SearchBox( + searchBarTopPadding = dynamicTopPadding, + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding(), + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection) + ), + hazeState = hazeState, + hazeStyle = hazeStyle + ) { boxHeight -> + KpmList( + viewModel = viewModel, + listState = listState, + scope = scope, + moduleConfirmContentMap = moduleConfirmContentMap, + showToast = showToast, + kpmUninstallSuccess = kpmUninstallSuccess, + kpmUninstallFailed = kpmUninstallFailed, + failedToCheckModuleFile = failedToCheckModuleFile, + uninstall = uninstall, + cancel = cancel, + confirmDialog = confirmDialog, + confirmTitle = confirmTitle, + scrollBehavior = scrollBehavior, + nestedScrollConnection = nestedScrollConnection, + hazeState = hazeState, + innerPadding = innerPadding, + bottomInnerPadding = bottomInnerPadding, + boxHeight = boxHeight, + layoutDirection = layoutDirection + ) + } + } + } + } +} + +private suspend fun handleModuleInstall( + tempFile: File, + isEmbed: Boolean, + viewModel: KpmViewModel, + showToast: suspend (String) -> Unit, + kpmInstallSuccess: String, + kpmInstallFailed: String +) { + var moduleId: String? = null + try { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep 'name='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + if (result.isSuccess) { + for (line in result.out) { + if (line.startsWith("name=")) { + moduleId = line.substringAfter("name=").trim() + break + } + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module ID from strings command: ${e.message}", e) + } + + if (moduleId == null || moduleId.isEmpty()) { + Log.e("KsuCli", "Failed to extract module ID from file: ${tempFile.name}") + showToast(kpmInstallFailed) + tempFile.delete() + return + } + + val targetPath = "/data/adb/kpm/$moduleId.kpm" + + try { + if (isEmbed) { + val shell = getRootShell() + shell.newJob().add("mkdir -p /data/adb/kpm").exec() + shell.newJob().add("cp ${tempFile.absolutePath} $targetPath").exec() + } + + val loadResult = loadKpmModule(tempFile.absolutePath) + if (loadResult.startsWith("Error")) { + Log.e("KsuCli", "Failed to load KPM module: $loadResult") + showToast(kpmInstallFailed) + } else { + viewModel.fetchModuleList() + showToast(kpmInstallSuccess) + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to load KPM module: ${e.message}", e) + showToast(kpmInstallFailed) + } + tempFile.delete() +} + +private suspend fun handleModuleUninstall( + module: KpmViewModel.ModuleInfo, + viewModel: KpmViewModel, + showToast: suspend (String) -> Unit, + kpmUninstallSuccess: String, + kpmUninstallFailed: String, + failedToCheckModuleFile: String, + uninstall: String, + cancel: String, + confirmTitle : String, + confirmContent : String, + confirmDialog: ConfirmDialogHandle +) { + val moduleFileName = "${module.id}.kpm" + val moduleFilePath = "/data/adb/kpm/$moduleFileName" + + val fileExists = try { + val shell = getRootShell() + val result = shell.newJob().add("ls /data/adb/kpm/$moduleFileName").exec() + result.isSuccess + } catch (e: Exception) { + Log.e("KsuCli", "Failed to check module file existence: ${e.message}", e) + showToast(failedToCheckModuleFile) + false + } + + val confirmResult = confirmDialog.awaitConfirm( + title = confirmTitle, + content = confirmContent, + confirm = uninstall, + dismiss = cancel + ) + + if (confirmResult == ConfirmResult.Confirmed) { + try { + val unloadResult = unloadKpmModule(module.id) + if (unloadResult.startsWith("Error")) { + Log.e("KsuCli", "Failed to unload KPM module: $unloadResult") + showToast(kpmUninstallFailed) + return + } + + if (fileExists) { + val shell = getRootShell() + shell.newJob().add("rm $moduleFilePath").exec() + } + + viewModel.fetchModuleList() + showToast(kpmUninstallSuccess) + } catch (e: Exception) { + Log.e("KsuCli", "Failed to unload KPM module: ${e.message}", e) + showToast(kpmUninstallFailed) + } + } +} + +@Composable +private fun KpmList( + viewModel: KpmViewModel, + listState: LazyListState, + scope: CoroutineScope, + moduleConfirmContentMap: Map, + showToast: suspend (String) -> Unit, + kpmUninstallSuccess: String, + kpmUninstallFailed: String, + failedToCheckModuleFile: String, + uninstall: String, + cancel: String, + confirmDialog: ConfirmDialogHandle, + confirmTitle: String, + scrollBehavior: ScrollBehavior, + nestedScrollConnection: NestedScrollConnection, + hazeState: HazeState, + innerPadding: PaddingValues, + bottomInnerPadding: Dp, + boxHeight: MutableState, + layoutDirection: LayoutDirection +) { + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) + var isNoticeClosed by remember { mutableStateOf(sharedPreferences.getBoolean("is_notice_closed", false)) } + + var isRefreshing by rememberSaveable { mutableStateOf(false) } + val pullToRefreshState = rememberPullToRefreshState() + val refreshTexts = remember { + listOf( + context.getString(R.string.refresh_pulling), + context.getString(R.string.refresh_release), + context.getString(R.string.refresh_refresh), + context.getString(R.string.refresh_complete), + ) + } + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(350) + viewModel.fetchModuleList() + isRefreshing = false + } + } + + PullToRefresh( + isRefreshing = isRefreshing, + pullToRefreshState = pullToRefreshState, + onRefresh = { if (!isRefreshing) isRefreshing = true }, + refreshTexts = refreshTexts, + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding() + boxHeight.value + 6.dp, + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + ), + ) { + LazyColumn( + state = listState, + modifier = Modifier + .height(getWindowSize().height.dp) + .scrollEndHaptic() + .overScrollVertical() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .nestedScroll(nestedScrollConnection) + .hazeSource(state = hazeState), + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding() + boxHeight.value + 6.dp, + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + ), + overscrollEffect = null, + ) { + if (!isNoticeClosed) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + tint = colorScheme.onBackground + ) + + Text( + text = stringResource(R.string.kernel_module_notice), + modifier = Modifier.weight(1f), + color = colorScheme.onBackground + ) + + IconButton( + onClick = { + isNoticeClosed = true + sharedPreferences.edit { putBoolean("is_notice_closed", true) } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close_notice), + tint = colorScheme.onBackground + ) + } + } + } + } + } + + items(viewModel.moduleList) { module -> + KpmModuleItem( + module = module, + onUninstall = { + scope.launch { + val confirmContent = moduleConfirmContentMap[module.id] ?: "" + handleModuleUninstall( + module = module, + viewModel = viewModel, + showToast = showToast, + kpmUninstallSuccess = kpmUninstallSuccess, + kpmUninstallFailed = kpmUninstallFailed, + failedToCheckModuleFile = failedToCheckModuleFile, + uninstall = uninstall, + cancel = cancel, + confirmDialog = confirmDialog, + confirmTitle = confirmTitle, + confirmContent = confirmContent + ) + } + }, + onControl = { + viewModel.loadModuleDetail(module.id) + } + ) + } + item { + Spacer(Modifier.height(bottomInnerPadding)) + } + } + } +} + +@Composable +private fun KpmModuleItem( + module: KpmViewModel.ModuleInfo, + onUninstall: () -> Unit, + onControl: () -> Unit +) { + val viewModel: KpmViewModel = viewModel() + val scope = rememberCoroutineScope() + val context = LocalContext.current + val successMessage = stringResource(R.string.kpm_control_success) + val failureMessage = stringResource(R.string.kpm_control_failed) + + val showToast: suspend (String) -> Unit = { msg -> + scope.launch(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + + val showInputDialog = viewModel.showInputDialog && viewModel.selectedModuleId == module.id + val showDialogState = remember { mutableStateOf(false) } + + LaunchedEffect(viewModel.showInputDialog, viewModel.selectedModuleId) { + showDialogState.value = viewModel.showInputDialog && viewModel.selectedModuleId == module.id + } + + if (showInputDialog) { + SuperDialog( + show = showDialogState, + title = stringResource(R.string.kpm_control), + onDismissRequest = { + showDialogState.value = false + viewModel.hideInputDialog() + }, + content = { + Column { + TextField( + value = viewModel.inputArgs, + onValueChange = { viewModel.updateInputArgs(it) }, + label = stringResource(R.string.kpm_args), + modifier = Modifier.fillMaxWidth(), + useLabelAsPlaceholder = viewModel.inputArgs.isEmpty() + ) + if (viewModel.inputArgs.isEmpty() && module.args.isNotEmpty()) { + Text( + text = module.args, + color = colorScheme.onSurfaceVariantSummary, + fontSize = MiuixTheme.textStyles.body2.fontSize, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + text = stringResource(R.string.cancel), + onClick = { + showDialogState.value = false + viewModel.hideInputDialog() + }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(20.dp)) + TextButton( + text = stringResource(R.string.confirm), + onClick = { + scope.launch { + val result = viewModel.executeControl() + val message = when (result) { + 0 -> successMessage + else -> failureMessage + } + showToast(message) + showDialogState.value = false + onControl() + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary() + ) + } + } + } + ) + } + + val isDark = isSystemInDarkTheme() + val onSurface = colorScheme.onSurface + val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f) + val actionIconTint = remember(isDark) { onSurface.copy(alpha = if (isDark) 0.7f else 0.9f) } + + Card( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp), + insideMargin = PaddingValues(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) { + val kpmVersion = stringResource(R.string.kpm_version) + val kpmAuthor = stringResource(R.string.kpm_author) + val kpmArgs = stringResource(R.string.kpm_args) + + SubcomposeLayout { constraints -> + val namePlaceable = subcompose("name") { + Text( + text = module.name, + fontSize = 17.sp, + fontWeight = FontWeight(550), + color = colorScheme.onSurface, + onTextLayout = { } + ) + }.first().measure(constraints) + + layout(namePlaceable.width, namePlaceable.height) { + namePlaceable.placeRelative(0, 0) + } + } + Text( + text = "$kpmVersion: ${module.version}", + fontSize = 12.sp, + modifier = Modifier.padding(top = 2.dp), + fontWeight = FontWeight(550), + color = colorScheme.onSurfaceVariantSummary + ) + Text( + text = "$kpmAuthor: ${module.author}", + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 1.dp), + fontWeight = FontWeight(550), + color = colorScheme.onSurfaceVariantSummary + ) + if (module.args.isNotEmpty()) { + Text( + text = "$kpmArgs: ${module.args}", + fontSize = 12.sp, + fontWeight = FontWeight(550), + color = colorScheme.onSurfaceVariantSummary + ) + } + } + } + + if (module.description.isNotBlank()) { + Text( + text = module.description, + fontSize = 14.sp, + color = colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 2.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 4 + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = 0.5.dp, + color = colorScheme.outline.copy(alpha = 0.5f) + ) + + Row { + AnimatedVisibility( + visible = module.hasAction, + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton( + backgroundColor = secondaryContainer, + minHeight = 35.dp, + minWidth = 35.dp, + onClick = { + viewModel.showInputDialog(module.id) + }, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Filled.Settings, + tint = actionIconTint, + contentDescription = stringResource(R.string.kpm_control) + ) + } + } + + Spacer(Modifier.weight(1f)) + + IconButton( + minHeight = 35.dp, + minWidth = 35.dp, + onClick = onUninstall, + backgroundColor = secondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Filled.Delete, + tint = actionIconTint, + contentDescription = null + ) + Text( + modifier = Modifier.padding(start = 4.dp, end = 3.dp), + text = stringResource(R.string.kpm_uninstall), + color = actionIconTint, + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + } + } + } + } +} + +private fun checkStringsCommand(tempFile: File): Int { + val shell = getRootShell() + val command = "strings ${tempFile.absolutePath} | grep -E 'name=|version=|license=|author='" + val result = shell.newJob().add(command).to(ArrayList(), null).exec() + + if (!result.isSuccess) { + return 0 + } + + var matchCount = 0 + val keywords = listOf("name=", "version=", "license=", "author=") + var nameExists = false + + for (line in result.out) { + if (!nameExists && line.startsWith("name=")) { + nameExists = true + matchCount++ + } else if (nameExists) { + for (keyword in keywords) { + if (line.startsWith(keyword)) { + matchCount++ + break + } + } + } + } + + return if (nameExists) matchCount else 0 +} + +private fun isElfFile(tempFile: File): Boolean { + val elfMagic = byteArrayOf(0x7F, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte()) + val fileBytes = ByteArray(4) + FileInputStream(tempFile).use { input -> + input.read(fileBytes) + } + return fileBytes.contentEquals(elfMagic) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt new file mode 100644 index 00000000..e4f1882c --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/KpmViewModel.kt @@ -0,0 +1,181 @@ +package com.sukisu.ultra.ui.viewmodel + +import android.util.Log +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sukisu.ultra.ui.component.SearchStatus +import com.sukisu.ultra.ui.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * @author ShirkNeko + * @date 2025/5/31. + */ +class KpmViewModel : ViewModel() { + private var _moduleList by mutableStateOf(emptyList()) + + val moduleList by derivedStateOf { + val searchText = _searchStatus.value.searchText + if (searchText.isEmpty()) { + _moduleList + } else { + _moduleList.filter { + it.id.contains(searchText, true) || + it.name.contains(searchText, true) || + it.description.contains(searchText, true) || + it.author.contains(searchText, true) || + it.version.contains(searchText, true) + } + } + } + + private val _searchStatus = mutableStateOf(SearchStatus("")) + val searchStatus: State = _searchStatus + + var isRefreshing by mutableStateOf(false) + private set + + var currentModuleDetail by mutableStateOf("") + private set + + fun fetchModuleList() { + viewModelScope.launch { + isRefreshing = true + try { + val moduleCount = getKpmModuleCount() + Log.d("KsuCli", "Module count: $moduleCount") + + _moduleList = getAllKpmModuleInfo() + + // 获取 KPM 版本信息 + val kpmVersion = getKpmVersion() + Log.d("KsuCli", "KPM Version: $kpmVersion") + } catch (e: Exception) { + Log.e("KsuCli", "获取模块列表失败", e) + } finally { + isRefreshing = false + } + } + } + + private fun getAllKpmModuleInfo(): List { + val result = mutableListOf() + try { + val str = listKpmModules() + val moduleNames = str + .split("\n") + .filter { it.isNotBlank() } + + for (name in moduleNames) { + try { + val moduleInfo = parseModuleInfo(name) + moduleInfo?.let { result.add(it) } + } catch (e: Exception) { + Log.e("KsuCli", "Error processing module $name", e) + } + } + } catch (e: Exception) { + Log.e("KsuCli", "Failed to get module list", e) + } + return result + } + + private fun parseModuleInfo(name: String): ModuleInfo? { + val info = getKpmModuleInfo(name) + if (info.isBlank()) return null + + val properties = info.lineSequence() + .filter { line -> + val trimmed = line.trim() + trimmed.isNotEmpty() && !trimmed.startsWith("#") + } + .mapNotNull { line -> + line.split("=", limit = 2).let { parts -> + when (parts.size) { + 2 -> parts[0].trim() to parts[1].trim() + 1 -> parts[0].trim() to "" + else -> null + } + } + } + .toMap() + + return ModuleInfo( + id = name, + name = properties["name"] ?: name, + version = properties["version"] ?: "", + author = properties["author"] ?: "", + description = properties["description"] ?: "", + args = properties["args"] ?: "", + enabled = true, + hasAction = true + ) + } + + fun loadModuleDetail(moduleId: String) { + viewModelScope.launch { + try { + currentModuleDetail = withContext(Dispatchers.IO) { + getKpmModuleInfo(moduleId) + } + Log.d("KsuCli", "Module detail loaded: $currentModuleDetail") + } catch (e: Exception) { + Log.e("KsuCli", "Failed to load module detail", e) + currentModuleDetail = "Error: ${e.message}" + } + } + } + + var showInputDialog by mutableStateOf(false) + private set + + var selectedModuleId by mutableStateOf(null) + private set + + var inputArgs by mutableStateOf("") + private set + + fun showInputDialog(moduleId: String) { + selectedModuleId = moduleId + showInputDialog = true + } + + fun hideInputDialog() { + showInputDialog = false + selectedModuleId = null + inputArgs = "" + } + + fun updateInputArgs(args: String) { + inputArgs = args + } + + fun executeControl(): Int { + val moduleId = selectedModuleId ?: return -1 + val result = controlKpmModule(moduleId, inputArgs) + hideInputDialog() + return result + } + + fun updateSearchText(text: String) { + _searchStatus.value.searchText = text + } + + 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 + ) +} 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 0dc77eeb..a82489ce 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -246,4 +246,93 @@ 浅色 深色 跟随系统 + + 刷入完成 + 准备中… + 清理文件… + 复制文件… + 解压刷机工具… + 修补刷机脚本… + 正在刷入内核… + 刷入已完成 + 选择刷入槽位 + 请选择要刷入boot的目标槽位 + 槽位 A + 槽位 B + 已选槽位: %1$s + 获取原始槽位 + 设置指定槽位 + 恢复默认槽位 + 当前系统默认槽位:%1$s + 复制失败 + 未知错误 + 刷入失败 + LKM 修复/安装 + 刷入 AnyKernel3 + 内核版本:%1$s + 使用的修补工具:%1$s + 配置 + 应用程序设置 + 工具 + 需要root权限 + KPM 补丁 + 用于添加额外的KPM功能 + KPM 补丁 + 在刷入前对内核镜像应用KPM补丁 + KPM 撤销补丁 + 撤销之前应用的KPM补丁 + KPM 补丁已启用 + KPM 撤销补丁已启用 + KPM 补丁模式 + KPM 撤销补丁模式 + 准备KPM工具 + 应用KPM补丁 + 撤销KPM补丁 + 找到镜像文件: %s + KPM 补丁应用成功 + KPM 补丁撤销成功 + 文件重新打包成功 + 解压zip文件失败 + 未找到镜像文件 + KPM 补丁失败 + KPM 撤销补丁失败 + KPM 补丁操作失败: %s + 跟随内核 + 原样使用内核,不进行任何KPM修改 + 内核刷入 + AnyKernel3 内核 + 刷入AnyKernel3格式的内核zip包 + + KPM + 当前没有安装内核模块 + 版本 + 作者 + 卸载 + 卸载成功 + 卸载失败 + kpm 模块加载成功 + kpm 模块加载失败 + 参数 + 执行 + KPM 版本 + 关闭 + 以下内核模块功能由 KernelPatch 开发,并经过修改后加入了 SukiSU Ultra 的内核模块功能 + SukiSU Ultra 期待 + 成功 + 失败 + SukiSU Ultra 在未来将是 KSU 的相对独立分支,但我们仍然感谢官方 KernelSU 和 MKSU 等的贡献! + 不支持 + 支持 + 内核未修补 + 内核未配置 + 自定义设置 + KPM 安装 + 加载 + 嵌入 + 请选择:%1$s 模块安装模式\n\n加载:临时加载模块\n嵌入:永久安装到系统中 + 文件类型不正确!请选择 .kpm 文件 + 卸载 + 以下 KPM 将被卸载:%s + 无法检查模块文件是否存在 + 取消 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 9967d574..a0a93a82 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -250,4 +250,93 @@ Light Dark Follow System + + Flash Complete + Preparing… + Cleaning files… + Copying files… + Extracting flash tool… + Patching flash script… + Flashing kernel… + Flash completed + Select Flash Slot + Please select the target slot for flashing boot + Slot A + Slot B + Selected slot: %1$s + Getting the original slot + Setting the specified slot + Restore Default Slot + Current system default slot:%1$s + Copy failed + Unknown error + Flash failed + LKM repair/installation + Flashing AnyKernel3 + Kernel version:%1$s + Using the patching tool:%1$s + Configure + Application Settings + Tools + Requires root privileges + KPM Patch + For adding additional KPM features + KPM Patch + Apply KPM patch to kernel image before flashing + KPM Undo Patch + Undo previously applied KPM patch + KPM patch enabled + KPM undo patch enabled + KPM Patch Mode + KPM Undo Patch Mode + Preparing KPM tools + Applying KPM patch + Undoing KPM patch + Found Image file: %s + KPM patch applied successfully + KPM patch undone successfully + File repacked successfully + Failed to extract zip file + Image file not found + KPM patch failed + KPM undo patch failed + KPM patch operation failed: %s + Follow Kernel + Use kernel as-is without any KPM modifications + Kernel Flashing + AnyKernel3 Kernel + Flash AnyKernel3 format kernel zip + + KPM + No installed kernel modules at this time + Version + Author + Uninstall + Uninstalled successfully + Failed to uninstall + Load of kpm module successful + Load of kpm module failed + Parameters + Execute + KPM Version + Close + The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra + SukiSU Ultra Look forward to + Success + Failed + SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions! + Unsupported + Supported + Kernel not patched + Kernel not configured + Custom settings + KPM Install + Load + Embed + Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system + Incorrect file type! Please select .kpm file + Uninstall + The following KPM will be uninstalled: %s + Unable to check if module file exists + Cancel diff --git a/manager/gradle/libs.versions.toml b/manager/gradle/libs.versions.toml index 74fb84ea..559825ce 100644 --- a/manager/gradle/libs.versions.toml +++ b/manager/gradle/libs.versions.toml @@ -20,6 +20,7 @@ cmaker = "1.2" miuix = "0.6.1" haze = "1.7.0" capsule = "2.1.1" +documentfile = "1.1.0" [plugins] agp-app = { id = "com.android.application", version.ref = "agp" } @@ -73,4 +74,5 @@ miuix = { module = "top.yukonga.miuix.kmp:miuix-android", version.ref = "miuix" haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" } -capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } \ No newline at end of file +capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } \ No newline at end of file