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 <rifat.44.azad.rifs@gmail.com>
Co-authored-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
Co-authored-by: KOWX712 <leecc0503@gmail.com>
Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
This commit is contained in:
ShirkNeko
2025-11-21 13:08:54 +08:00
parent 4ea5c8f450
commit 932fabd35c
12 changed files with 580 additions and 280 deletions

View File

@@ -25,6 +25,26 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
<data android:scheme="content" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
</activity>
<activity

View File

@@ -39,6 +39,7 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
@@ -105,6 +106,8 @@ class MainActivity : ComponentActivity() {
KernelSUTheme(colorMode = colorMode, keyColor = keyColor) {
val navController = rememberNavController()
val navigator = navController.rememberDestinationsNavigator()
val initialIntent = remember { intent }
Scaffold {
DestinationsNavHost(
@@ -146,6 +149,7 @@ class MainActivity : ComponentActivity() {
}
)
}
HandleZipFileIntent(initialIntent, navigator)
}
}
}

View File

@@ -0,0 +1,290 @@
package com.sukisu.ultra.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.kernelFlash.KpmPatchOption
import com.sukisu.ultra.ui.kernelFlash.KpmPatchSelectionDialog
import com.sukisu.ultra.ui.kernelFlash.component.SlotSelectionDialog
import com.sukisu.ultra.ui.screen.FlashIt
import com.sukisu.ultra.ui.util.getFileName
import com.sukisu.ultra.ui.util.isAbDevice
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
private sealed class DialogState {
data object None : DialogState()
data class SlotSelection(val kernelUri: Uri) : DialogState()
data class KpmSelection(val kernelUri: Uri, val slot: String?) : DialogState()
}
@SuppressLint("StringFormatInvalid")
@Composable
fun HandleZipFileIntent(
intent: Intent?,
navigator: DestinationsNavigator
) {
val context = LocalContext.current
val confirmDialog = rememberConfirmDialog()
val scope = rememberCoroutineScope()
var processed by remember { mutableStateOf(false) }
var dialogState by remember { mutableStateOf<DialogState>(DialogState.None) }
var selectedSlot by remember { mutableStateOf<String?>(null) }
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
LaunchedEffect(intent) {
if (intent == null || processed) return@LaunchedEffect
val zipUris = mutableSetOf<Uri>()
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
}
}

View File

@@ -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
)
}

View File

@@ -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<RootGraph>
@@ -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)
)
}
}

View File

@@ -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<String?>(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
}
}

View File

@@ -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()
}
}

View File

@@ -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<String, DownloadResult> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, DownloadResult>()
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)

View File

@@ -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(

View File

@@ -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(

View File

@@ -368,4 +368,12 @@
<string name="allowlist_restore_summary_picker">选择备份文件进行导入</string>
<string name="allowlist_restore_success">还原成功,重启生效</string>
<string name="allowlist_restore_failed">还原失败</string>
<!-- Zip File Type Labels -->
<string name="zip_type_module"> (模块)</string>
<string name="zip_type_kernel"> (内核)</string>
<string name="zip_type_unknown"> (未知)</string>
<string name="zip_file_unknown">unknown.zip</string>
<!-- Zip Install Prompts -->
<string name="kernel_install_prompt_with_name">将安装以下内核:%1$s</string>
<string name="mixed_install_prompt_with_name">将安装以下文件:%1$s</string>
</resources>

View File

@@ -376,4 +376,12 @@
<string name="allowlist_restore_summary_picker">Choose a backup file to import</string>
<string name="allowlist_restore_success">Restore succeeded</string>
<string name="allowlist_restore_failed">Restore failed</string>
<!-- Zip File Type Labels -->
<string name="zip_type_module"> (Module)</string>
<string name="zip_type_kernel"> (Kernel)</string>
<string name="zip_type_unknown"> (Unknown)</string>
<string name="zip_file_unknown">unknown.zip</string>
<!-- Zip Install Prompts -->
<string name="kernel_install_prompt_with_name">The following kernels will be installed: %1$s</string>
<string name="mixed_install_prompt_with_name">The following files will be installed: %1$s</string>
</resources>