From 932fabd35cae6596289afbb0ffe3d457bc07dc77 Mon Sep 17 00:00:00 2001 From: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:08:54 +0800 Subject: [PATCH] Step 6: feat: add direct zip flash for AnyKernel3 and modules - fix Chrome zip open failure - one-tap flash AnyKernel3 kernel packages - bulk install with state de-duplication - refine share UI & color scheme --------------------------------- Co-Authored-By: Der_Googler <54764558+dergoogler@users.noreply.github.com> Co-authored-by: rifsxd Co-authored-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com> Co-authored-by: KOWX712 Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com> --- manager/app/src/main/AndroidManifest.xml | 20 ++ .../java/com/sukisu/ultra/ui/MainActivity.kt | 4 + .../sukisu/ultra/ui/ZipFileIntentHandler.kt | 290 ++++++++++++++++++ .../ultra/ui/kernelFlash/AnyKernel3Flow.kt | 26 +- .../ultra/ui/kernelFlash/KernelFlash.kt | 139 ++++----- .../component/SlotSelectionDialog.kt | 44 ++- .../ui/kernelFlash/state/KernelFlashState.kt | 49 ++- .../kernelFlash/util/RemoteToolsDownloader.kt | 190 ++++-------- .../java/com/sukisu/ultra/ui/screen/Flash.kt | 21 ++ .../com/sukisu/ultra/ui/screen/Install.kt | 61 ++-- .../src/main/res/values-zh-rCN/strings.xml | 8 + manager/app/src/main/res/values/strings.xml | 8 + 12 files changed, 580 insertions(+), 280 deletions(-) create mode 100644 manager/app/src/main/java/com/sukisu/ultra/ui/ZipFileIntentHandler.kt diff --git a/manager/app/src/main/AndroidManifest.xml b/manager/app/src/main/AndroidManifest.xml index 025709cc..a5efbef3 100644 --- a/manager/app/src/main/AndroidManifest.xml +++ b/manager/app/src/main/AndroidManifest.xml @@ -25,6 +25,26 @@ + + + + + + + + + + + + + + + + + + + + (DialogState.None) } + var selectedSlot by remember { mutableStateOf(null) } + var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) } + + LaunchedEffect(intent) { + if (intent == null || processed) return@LaunchedEffect + + val zipUris = mutableSetOf() + + fun isModuleFile(uri: Uri?): Boolean { + if (uri == null) return false + val uriString = uri.toString() + return uriString.endsWith(".zip", ignoreCase = true) || + uriString.endsWith(".apk", ignoreCase = true) + } + + when (intent.action) { + Intent.ACTION_VIEW, Intent.ACTION_SEND -> { + val data = intent.data + val stream = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + + when { + isModuleFile(data) -> { + zipUris.add(data!!) + } + isModuleFile(stream) -> { + zipUris.add(stream!!) + } + } + } + Intent.ACTION_SEND_MULTIPLE -> { + val streamList = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + streamList?.forEach { uri -> + if (isModuleFile(uri)) { + zipUris.add(uri) + } + } + } + } + + intent.clipData?.let { clipData -> + for (i in 0 until clipData.itemCount) { + clipData.getItemAt(i)?.uri?.let { uri -> + if (isModuleFile(uri)) { + zipUris.add(uri) + } + } + } + } + + if (zipUris.isNotEmpty()) { + processed = true + + val zipUrisList = zipUris.toList() + + // 检测 zip 文件类型 + val zipTypes = withContext(Dispatchers.IO) { + zipUrisList.map { uri -> detectZipType(context, uri) } + } + + val moduleUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.MODULE } + val kernelUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.KERNEL } + val unknownUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.UNKNOWN } + + val finalModuleUris = moduleUris + unknownUris + + val fileNames = zipUrisList.mapIndexed { index, uri -> + val fileName = uri.getFileName(context) ?: context.getString(R.string.zip_file_unknown) + val type = when (zipTypes[index]) { + ZipType.MODULE -> context.getString(R.string.zip_type_module) + ZipType.KERNEL -> context.getString(R.string.zip_type_kernel) + ZipType.UNKNOWN -> context.getString(R.string.zip_type_unknown) + } + "\n${index + 1}. $fileName$type" + }.joinToString("") + + val confirmContent = when { + moduleUris.isNotEmpty() && kernelUris.isNotEmpty() -> { + context.getString(R.string.mixed_install_prompt_with_name, fileNames) + } + kernelUris.isNotEmpty() -> { + context.getString(R.string.kernel_install_prompt_with_name, fileNames) + } + else -> { + context.getString(R.string.module_install_prompt_with_name, fileNames) + } + } + + val confirmTitle = if (kernelUris.isNotEmpty() && moduleUris.isEmpty()) { + context.getString(R.string.horizon_kernel) + } else { + context.getString(R.string.module) + } + + val result = confirmDialog.awaitConfirm( + title = confirmTitle, + content = confirmContent + ) + + if (result == ConfirmResult.Confirmed) { + if (finalModuleUris.isNotEmpty()) { + navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(finalModuleUris))) { + launchSingleTop = true + } + } + + // 处理内核安装 + if (kernelUris.isNotEmpty()) { + val kernelUri = kernelUris.first() + val isAbDeviceValue = withContext(Dispatchers.IO) { isAbDevice() } + dialogState = if (isAbDeviceValue) { + // AB设备:先选择槽位 + DialogState.SlotSelection(kernelUri) + } else { + // 非AB设备:直接选择KPM + DialogState.KpmSelection(kernelUri, null) + } + } + } + } + } + + // 槽位选择 + when (val state = dialogState) { + is DialogState.SlotSelection -> { + SlotSelectionDialog( + show = true, + onDismiss = { + dialogState = DialogState.None + selectedSlot = null + kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL + }, + onSlotSelected = { slot -> + selectedSlot = slot + dialogState = DialogState.None + scope.launch { + delay(300) + dialogState = DialogState.KpmSelection(state.kernelUri, slot) + } + } + ) + } + is DialogState.KpmSelection -> { + KpmPatchSelectionDialog( + show = true, + currentOption = kpmPatchOption, + onDismiss = { + dialogState = DialogState.None + selectedSlot = null + kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL + }, + onOptionSelected = { option -> + kpmPatchOption = option + dialogState = DialogState.None + + navigator.navigate( + KernelFlashScreenDestination( + kernelUri = state.kernelUri, + selectedSlot = state.slot, + kpmPatchEnabled = option == KpmPatchOption.PATCH_KPM, + kpmUndoPatch = option == KpmPatchOption.UNDO_PATCH_KPM + ) + ) { + launchSingleTop = true + } + + selectedSlot = null + kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL + } + ) + } + is DialogState.None -> { + } + } +} + +enum class ZipType { + MODULE, + KERNEL, + UNKNOWN +} + +fun detectZipType(context: Context, uri: Uri): ZipType { + // 首先检查文件扩展名,APK 文件可能是模块 + val uriString = uri.toString().lowercase() + val isApk = uriString.endsWith(".apk", ignoreCase = true) + + return try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + java.util.zip.ZipInputStream(inputStream).use { zipStream -> + var hasModuleProp = false + var hasToolsFolder = false + var hasAnykernelSh = false + + var entry = zipStream.nextEntry + while (entry != null) { + val entryName = entry.name.lowercase() + + when { + entryName == "module.prop" || entryName.endsWith("/module.prop") -> { + hasModuleProp = true + } + entryName.startsWith("tools/") || entryName == "tools" -> { + hasToolsFolder = true + } + entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> { + hasAnykernelSh = true + } + } + + zipStream.closeEntry() + entry = zipStream.nextEntry + } + + when { + hasModuleProp -> ZipType.MODULE + hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL + // APK 文件如果没有检测到其他类型,默认当作模块处理 + isApk -> ZipType.MODULE + else -> ZipType.UNKNOWN + } + } + } ?: run { + // 如果无法打开文件流,APK 文件默认当作模块处理 + if (isApk) ZipType.MODULE else ZipType.UNKNOWN + } + } catch (e: java.io.IOException) { + e.printStackTrace() + // 如果是 APK 文件但读取失败,仍然当作模块处理 + if (isApk) ZipType.MODULE else ZipType.UNKNOWN + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/AnyKernel3Flow.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/AnyKernel3Flow.kt index cc55ff78..ff02de4f 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/AnyKernel3Flow.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/AnyKernel3Flow.kt @@ -51,7 +51,9 @@ data class AnyKernel3State( val onSlotSelected: (String) -> Unit, val onDismissSlotDialog: () -> Unit, val onOptionSelected: (KpmPatchOption) -> Unit, - val onDismissPatchDialog: () -> Unit + val onDismissPatchDialog: () -> Unit, + val onReopenSlotDialog: (InstallMethod.HorizonKernel) -> Unit, + val onReopenKpmDialog: (InstallMethod.HorizonKernel) -> Unit ) @Composable @@ -69,7 +71,7 @@ fun rememberAnyKernel3State( val onHorizonKernelSelected: (InstallMethod.HorizonKernel) -> Unit = { method -> val uri = method.uri if (uri != null) { - if (isAbDevice) { + if (isAbDevice && method.slot == null) { tempKernelUri = uri showSlotSelectionDialog = true } else { @@ -78,9 +80,22 @@ fun rememberAnyKernel3State( } } } + + val onReopenSlotDialog: (InstallMethod.HorizonKernel) -> Unit = { method -> + val uri = method.uri + if (uri != null && isAbDevice) { + tempKernelUri = uri + showSlotSelectionDialog = true + } + } + + val onReopenKpmDialog: (InstallMethod.HorizonKernel) -> Unit = { method -> + installMethodState.value = method + showKpmPatchDialog = true + } val onSlotSelected: (String) -> Unit = { slot -> - val uri = tempKernelUri + val uri = tempKernelUri ?: (installMethodState.value as? InstallMethod.HorizonKernel)?.uri if (uri != null) { installMethodState.value = InstallMethod.HorizonKernel( uri = uri, @@ -95,7 +110,6 @@ fun rememberAnyKernel3State( val onDismissSlotDialog = { showSlotSelectionDialog = false - tempKernelUri = null } val onOptionSelected: (KpmPatchOption) -> Unit = { option -> @@ -135,7 +149,9 @@ fun rememberAnyKernel3State( onSlotSelected = onSlotSelected, onDismissSlotDialog = onDismissSlotDialog, onOptionSelected = onOptionSelected, - onDismissPatchDialog = onDismissPatchDialog + onDismissPatchDialog = onDismissPatchDialog, + onReopenSlotDialog = onReopenSlotDialog, + onReopenKpmDialog = onReopenKpmDialog ) } 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 index d93f2fb7..aaa94e02 100644 --- 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 @@ -1,15 +1,14 @@ package com.sukisu.ultra.ui.kernelFlash -import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Environment -import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity 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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -19,7 +18,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.platform.LocalContext @@ -27,7 +25,6 @@ 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 @@ -43,12 +40,14 @@ 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 import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.utils.scrollEndHaptic import java.io.File @@ -66,6 +65,15 @@ private object KernelFlashStateHolder { var currentKpmPatchEnabled: Boolean = false var currentKpmUndoPatch: Boolean = false var isFlashing = false + + fun clear() { + currentState = null + currentUri = null + currentSlot = null + currentKpmPatchEnabled = false + currentKpmUndoPatch = false + isFlashing = false + } } @Destination @@ -78,12 +86,6 @@ fun KernelFlashScreen( 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("") } @@ -109,17 +111,27 @@ fun KernelFlashScreen( } val flashState by horizonKernelState.state.collectAsState() + val activity = LocalActivity.current val onFlashComplete = { showFloatAction = true KernelFlashStateHolder.isFlashing = false + } + + // 如果是从外部打开的内核刷写,延迟1.5秒后自动退出 + LaunchedEffect(flashState.isCompleted, flashState.error) { + if (flashState.isCompleted && flashState.error.isEmpty()) { + val intent = activity?.intent + val isFromExternalIntent = intent?.action?.let { action -> + action == Intent.ACTION_VIEW || + action == Intent.ACTION_SEND || + action == Intent.ACTION_SEND_MULTIPLE + } ?: false - if (shouldAutoExit) { - scope.launch { + if (isFromExternalIntent) { delay(1500) - val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) - sharedPref.edit { remove("auto_exit_after_flash") } - (context as? ComponentActivity)?.finish() + KernelFlashStateHolder.clear() + activity.finish() } } } @@ -170,26 +182,17 @@ fun KernelFlashScreen( 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 + KernelFlashStateHolder.clear() } navigator.popBackStack() } } - DisposableEffect(shouldAutoExit) { + // 清理状态 + DisposableEffect(Unit) { onDispose { - if (shouldAutoExit) { - KernelFlashStateHolder.currentState = null - KernelFlashStateHolder.currentUri = null - KernelFlashStateHolder.currentSlot = null - KernelFlashStateHolder.currentKpmPatchEnabled = false - KernelFlashStateHolder.currentKpmUndoPatch = false - KernelFlashStateHolder.isFlashing = false + if (flashState.isCompleted || flashState.error.isNotEmpty()) { + KernelFlashStateHolder.clear() } } } @@ -274,14 +277,14 @@ private fun FlashProgressIndicator( kpmPatchEnabled: Boolean = false, kpmUndoPatch: Boolean = false ) { - val progressColor = when { + val statusColor = when { flashState.error.isNotEmpty() -> colorScheme.error - flashState.isCompleted -> colorScheme.secondary + flashState.isCompleted -> colorScheme.primary else -> colorScheme.primary } val progress = animateFloatAsState( - targetValue = flashState.progress, + targetValue = flashState.progress.coerceIn(0f, 1f), label = "FlashProgress" ) @@ -306,8 +309,9 @@ private fun FlashProgressIndicator( flashState.isCompleted -> stringResource(R.string.flash_success) else -> stringResource(R.string.flashing) }, - fontWeight = FontWeight.Bold, - color = progressColor + fontSize = MiuixTheme.textStyles.title4.fontSize, + fontWeight = FontWeight.Medium, + color = statusColor ) when { @@ -322,7 +326,7 @@ private fun FlashProgressIndicator( Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, - tint = colorScheme.secondary + tint = colorScheme.primary ) } } @@ -330,67 +334,44 @@ private fun FlashProgressIndicator( // KPM状态显示 if (kpmPatchEnabled || kpmUndoPatch) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.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, + fontSize = MiuixTheme.textStyles.body2.fontSize, color = colorScheme.onSurfaceVariantSummary ) - - Spacer(modifier = Modifier.height(8.dp)) } - val progressFraction = progress.value.coerceIn(0f, 1f) - Box( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(999.dp)) - .background(colorScheme.surfaceVariant) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(progressFraction) - .clip(RoundedCornerShape(999.dp)) - .background(progressColor) + if (flashState.currentStep.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = flashState.currentStep, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = colorScheme.onSurfaceVariantSummary ) } + Spacer(modifier = Modifier.height(12.dp)) + + LinearProgressIndicator( + progress = progress.value, + modifier = Modifier.fillMaxWidth() + ) + if (flashState.error.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Error, - contentDescription = null, - tint = colorScheme.error, - modifier = Modifier.size(16.dp) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - + Spacer(modifier = Modifier.height(12.dp)) Text( text = flashState.error, + fontSize = MiuixTheme.textStyles.body2.fontSize, color = colorScheme.onErrorContainer, modifier = Modifier .fillMaxWidth() + .padding(12.dp) .background( - colorScheme.errorContainer.copy(alpha = 0.8f) + colorScheme.errorContainer ) - .padding(8.dp) + .padding(12.dp) ) } } 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 index 78bc7131..d4c33c49 100644 --- 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 @@ -6,12 +6,17 @@ 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.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.unit.DpSize import androidx.compose.ui.unit.dp import com.sukisu.ultra.R +import com.sukisu.ultra.ui.util.getRootShell +import com.topjohnwu.superuser.ShellUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton @@ -35,11 +40,13 @@ fun SlotSelectionDialog( var selectedSlot by remember { mutableStateOf(null) } val showDialog = remember { mutableStateOf(show) } + val context = LocalContext.current + LaunchedEffect(show) { showDialog.value = show if (show) { try { - currentSlot = getCurrentSlot() + currentSlot = withContext(Dispatchers.IO) { getCurrentSlot() } // 设置默认选择为当前槽位 selectedSlot = when (currentSlot) { "a" -> "a" @@ -48,7 +55,7 @@ fun SlotSelectionDialog( } errorMessage = null } catch (e: Exception) { - errorMessage = e.message + errorMessage = context.getString(R.string.operation_failed) currentSlot = null } } @@ -85,9 +92,9 @@ fun SlotSelectionDialog( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp), - text = "Error: $errorMessage", + text = errorMessage ?: context.getString(R.string.operation_failed), fontSize = MiuixTheme.textStyles.body2.fontSize, - color = colorScheme.primary, + color = colorScheme.error, textAlign = TextAlign.Center ) } else { @@ -97,7 +104,7 @@ fun SlotSelectionDialog( .padding(horizontal = 24.dp, vertical = 8.dp), text = stringResource( id = R.string.current_slot, - currentSlot ?: "Unknown" + currentSlot?.uppercase() ?: context.getString(R.string.not_supported) ), fontSize = MiuixTheme.textStyles.body2.fontSize, color = colorScheme.onSurfaceVariantSummary, @@ -194,25 +201,16 @@ data class SlotOption( ) // 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" +private suspend fun getCurrentSlot(): String? { 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) { + val shell = getRootShell() + val result = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim() + if (result.startsWith("_")) { + result.substring(1) + } else { + result + }.takeIf { it.isNotEmpty() } + } catch (e: 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 index bf73ed23..9890d849 100644 --- 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 @@ -8,9 +8,10 @@ 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.getRootShell import com.sukisu.ultra.ui.util.install import com.sukisu.ultra.ui.util.rootAvailable -import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.ShellUtils import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -74,10 +75,6 @@ class HorizonKernelState { fun completeFlashing() { _state.update { it.copy(isCompleted = true, progress = 1f) } } - - fun reset() { - _state.value = FlashState() - } } class HorizonKernelWorker( @@ -157,7 +154,12 @@ class HorizonKernelWorker( 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") + originalSlot = try { + val shell = getRootShell() + ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim() + } catch (_: Exception) { + null + } state.updateStep(context.getString(R.string.horizon_setting_target_slot)) state.updateProgress(0.74f) @@ -308,7 +310,12 @@ class HorizonKernelWorker( } // 查找Image文件 - val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f") + val findImageResult = try { + val shell = getRootShell() + ShellUtils.fastCmd(shell, "find $extractDir -name '*Image*' -type f").trim() + } catch (_: Exception) { + throw IOException(context.getString(R.string.kpm_image_file_not_found)) + } if (findImageResult.isBlank()) { throw IOException(context.getString(R.string.kpm_image_file_not_found)) } @@ -398,11 +405,16 @@ class HorizonKernelWorker( // 检查设备是否为AB分区设备 private fun isAbDevice(): Boolean { - val abUpdate = runCommandGetOutput("getprop ro.build.ab_update") - if (!abUpdate.toBoolean()) return false + return try { + val shell = getRootShell() + val abUpdate = ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim() + if (!abUpdate.toBoolean()) return false - val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix") - return slotSuffix.isNotEmpty() + val slotSuffix = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim() + slotSuffix.isNotEmpty() + } catch (_: Exception) { + false + } } private fun cleanup() { @@ -429,7 +441,12 @@ class HorizonKernelWorker( @SuppressLint("StringFormatInvalid") private fun patch() { - val kernelVersion = runCommandGetOutput("cat /proc/version") + val kernelVersion = try { + val shell = getRootShell() + ShellUtils.fastCmd(shell, "cat /proc/version") + } catch (_: Exception) { + "" + } val versionRegex = """\d+\.\d+\.\d+""".toRegex() val version = kernelVersion.let { versionRegex.find(it) }?.value ?: "" val toolName = if (version.isNotEmpty()) { @@ -447,7 +464,9 @@ class HorizonKernelWorker( 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") + runCommand(false, + $$"sed -i '/chmod -R 755 tools bin;/i cp -f $$toolPath $AKHOME/tools;' $$binaryPath" + ) } private fun flash() { @@ -517,8 +536,4 @@ class HorizonKernelWorker( 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/RemoteToolsDownloader.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/kernelFlash/util/RemoteToolsDownloader.kt index b92656b4..17a17a22 100644 --- 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 @@ -2,6 +2,8 @@ package com.sukisu.ultra.ui.kernelFlash.util import android.content.Context import android.util.Log +import com.sukisu.ultra.ui.util.getRootShell +import com.topjohnwu.superuser.ShellUtils import kotlinx.coroutines.* import java.io.File import java.io.FileOutputStream @@ -23,8 +25,8 @@ class RemoteToolsDownloader( 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 CONNECTION_TIMEOUT = 10000 + private const val READ_TIMEOUT = 20000 // 最大重试次数 private const val MAX_RETRY_COUNT = 3 @@ -48,47 +50,26 @@ class RemoteToolsDownloader( suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map = withContext(Dispatchers.IO) { - val results = mutableMapOf() - listener?.onLog("Starting to prepare KPM tool files...") + File(workDir).mkdirs() - try { - // 确保工作目录存在 - File(workDir).mkdirs() + // 并行下载两个工具文件 + val results = mapOf( + "kptools" to async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }, + "kpimg" to async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) } + ).mapValues { it.value.await() } - // 并行下载两个工具文件 - 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) - } + // 设置 kptools 执行权限 + File(workDir, "kptools").takeIf { it.exists() }?.let { file -> + setExecutablePermission(file.absolutePath) + listener?.onLog("Set kptools execution permission") } - results.toMap() + 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") + + results } private suspend fun downloadSingleTool( @@ -96,43 +77,38 @@ class RemoteToolsDownloader( 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 = "" - // 重试机制 + 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 + } else { + lastError = result.errorMessage ?: "Unknown error" } - lastError = result.errorMessage ?: "Unknown error" - } catch (e: Exception) { - lastError = e.message ?: "Network exception" + lastError = "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)) - } + 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) } @@ -142,15 +118,10 @@ class RemoteToolsDownloader( targetFile: File, listener: DownloadProgressListener? ): DownloadResult = withContext(Dispatchers.IO) { - var connection: HttpURLConnection? = null try { - val url = URL(remoteUrl) - connection = url.openConnection() as HttpURLConnection - - // 设置连接参数 - connection.apply { + connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply { connectTimeout = CONNECTION_TIMEOUT readTimeout = READ_TIMEOUT requestMethod = "GET" @@ -159,22 +130,17 @@ class RemoteToolsDownloader( setRequestProperty("Connection", "close") } - // 建立连接 connection.connect() - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { + if (connection.responseCode != HttpURLConnection.HTTP_OK) { return@withContext DownloadResult( false, isRemoteSource = false, - errorMessage = "HTTP error code: $responseCode" + errorMessage = "HTTP error code: ${connection.responseCode}" ) } val fileLength = connection.contentLength - Log.d(TAG, "$fileName remote file size: $fileLength bytes") - - // 创建临时文件 val tempFile = File(targetFile.absolutePath + ".tmp") // 下载文件 @@ -182,40 +148,34 @@ class RemoteToolsDownloader( FileOutputStream(tempFile).use { output -> val buffer = ByteArray(8192) var totalBytes = 0 - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - // 检查协程是否被取消 + while (true) { ensureActive() + val bytesRead = input.read(buffer) + if (bytesRead == -1) break 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, + false, isRemoteSource = false, errorMessage = "File verification failed" ) } - // 移动临时文件到目标位置 - if (targetFile.exists()) { - targetFile.delete() - } - + targetFile.delete() if (!tempFile.renameTo(targetFile)) { tempFile.delete() return@withContext DownloadResult( @@ -227,7 +187,6 @@ class RemoteToolsDownloader( 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) { @@ -235,16 +194,10 @@ class RemoteToolsDownloader( 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}" - ) + DownloadResult(false, isRemoteSource = false, errorMessage = "Network exception: ${e.message}") } catch (e: Exception) { Log.e(TAG, "$fileName exception occurred during download", e) - DownloadResult(false, - isRemoteSource = false, - errorMessage = "Download exception: ${e.message}" - ) + DownloadResult(false, isRemoteSource = false, errorMessage = "Download exception: ${e.message}") } finally { connection?.disconnect() } @@ -255,61 +208,42 @@ class RemoteToolsDownloader( 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" + if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) { + val errorMsg = if (!targetFile.exists()) { + "Local $fileName file extraction failed" + } else { + "Local $fileName file verification 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 - ) + return@withContext DownloadResult(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) + DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg) } } private fun validateDownloadedFile(file: File, fileName: String): Boolean { - if (!file.exists()) { - Log.w(TAG, "$fileName file does not exist") + if (!file.exists() || file.length() < MIN_FILE_SIZE) { + Log.w(TAG, "$fileName file validation failed: exists=${file.exists()}, size=${file.length()}") return false } - val fileSize = file.length() - if (fileSize < MIN_FILE_SIZE) { - Log.w(TAG, "$fileName file is too small: $fileSize bytes") - return false - } - - try { + return try { file.inputStream().use { input -> val header = ByteArray(4) - val bytesRead = input.read(header) - - if (bytesRead < 4) { + if (input.read(header) < 4) { Log.w(TAG, "$fileName file header read incomplete") return false } @@ -324,20 +258,24 @@ class RemoteToolsDownloader( return false } - Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF") - return true + Log.d(TAG, "$fileName file verification passed, size: ${file.length()} bytes, ELF: $isELF") + true } } catch (e: Exception) { Log.w(TAG, "$fileName file verification exception", e) - return false + 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") + val shell = getRootShell() + if (ShellUtils.fastCmdResult(shell, "chmod a+rx $filePath")) { + Log.d(TAG, "Set execution permission for $filePath") + } else { + File(filePath).setExecutable(true, false) + Log.d(TAG, "Set execution permission using Java method for $filePath") + } } catch (e: Exception) { Log.w(TAG, "Failed to set execution permission: $filePath", e) try { @@ -351,11 +289,9 @@ class RemoteToolsDownloader( fun cleanup() { try { - File(workDir).listFiles()?.forEach { file -> - if (file.name.endsWith(".tmp")) { - file.delete() - Log.d(TAG, "Cleaned temporary file: ${file.name}") - } + File(workDir).listFiles()?.filter { it.name.endsWith(".tmp") }?.forEach { file -> + 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/Flash.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt index ed7e5026..a500ddad 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt @@ -1,5 +1,6 @@ package com.sukisu.ultra.ui.screen +import android.content.Intent import android.net.Uri import android.os.Environment import android.os.Parcelable @@ -37,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key +import androidx.activity.compose.LocalActivity import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -44,6 +46,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.dropUnlessResumed +import kotlinx.coroutines.delay import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -115,6 +118,7 @@ fun FlashScreen( var showFloatAction by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current + val activity = LocalActivity.current val scope = rememberCoroutineScope() val scrollState = rememberScrollState() var flashing by rememberSaveable { @@ -149,6 +153,23 @@ fun FlashScreen( } } + // 如果是从外部打开的模块安装,延迟1秒后自动退出 + LaunchedEffect(flashing, flashIt) { + if (flashing == FlashingStatus.SUCCESS && flashIt is FlashIt.FlashModules) { + val intent = activity?.intent + val isFromExternalIntent = intent?.action?.let { action -> + action == Intent.ACTION_VIEW || + action == Intent.ACTION_SEND || + action == Intent.ACTION_SEND_MULTIPLE + } ?: false + + if (isFromExternalIntent) { + delay(1000) + activity.finish() + } + } + } + Scaffold( topBar = { TopBar( 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 74e74916..2b233bab 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 @@ -362,7 +362,7 @@ fun InstallScreen( // AnyKernel3 刷写 (installMethod as? InstallMethod.HorizonKernel)?.let { method -> - if (method.slot != null) { + if (isAbDevice && method.slot != null) { Card( modifier = Modifier .fillMaxWidth() @@ -374,11 +374,13 @@ fun InstallScreen( if (method.slot == "a") stringResource(id = R.string.slot_a) else stringResource(id = R.string.slot_b) ), - onClick = {}, + onClick = { + anyKernel3State.onReopenSlotDialog(method) + }, leftAction = { Icon( Icons.Filled.SdStorage, - tint = colorScheme.onSurface, + tint = colorScheme.primary, modifier = Modifier.padding(end = 16.dp), contentDescription = null ) @@ -388,32 +390,33 @@ fun InstallScreen( } // 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 - ) - } - ) - } + 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) + KpmPatchOption.FOLLOW_KERNEL -> stringResource(R.string.kpm_follow_kernel_file) + }, + onClick = { + anyKernel3State.onReopenKpmDialog(method) + }, + leftAction = { + Icon( + Icons.Filled.Security, + tint = when (kpmPatchOption) { + KpmPatchOption.PATCH_KPM -> colorScheme.primary + KpmPatchOption.UNDO_PATCH_KPM -> colorScheme.secondary + KpmPatchOption.FOLLOW_KERNEL -> colorScheme.onSurfaceVariantSummary + }, + modifier = Modifier.padding(end = 16.dp), + contentDescription = null + ) + } + ) } } Button( 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 0b8fe3a0..3497f323 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -368,4 +368,12 @@ 选择备份文件进行导入 还原成功,重启生效 还原失败 + + (模块) + (内核) + (未知) + unknown.zip + + 将安装以下内核:%1$s + 将安装以下文件:%1$s diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 793eaedd..63150345 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -376,4 +376,12 @@ Choose a backup file to import Restore succeeded Restore failed + + (Module) + (Kernel) + (Unknown) + unknown.zip + + The following kernels will be installed: %1$s + The following files will be installed: %1$s