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" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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>
<activity <activity

View File

@@ -39,6 +39,7 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
@@ -105,6 +106,8 @@ class MainActivity : ComponentActivity() {
KernelSUTheme(colorMode = colorMode, keyColor = keyColor) { KernelSUTheme(colorMode = colorMode, keyColor = keyColor) {
val navController = rememberNavController() val navController = rememberNavController()
val navigator = navController.rememberDestinationsNavigator()
val initialIntent = remember { intent }
Scaffold { Scaffold {
DestinationsNavHost( 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 onSlotSelected: (String) -> Unit,
val onDismissSlotDialog: () -> Unit, val onDismissSlotDialog: () -> Unit,
val onOptionSelected: (KpmPatchOption) -> Unit, val onOptionSelected: (KpmPatchOption) -> Unit,
val onDismissPatchDialog: () -> Unit val onDismissPatchDialog: () -> Unit,
val onReopenSlotDialog: (InstallMethod.HorizonKernel) -> Unit,
val onReopenKpmDialog: (InstallMethod.HorizonKernel) -> Unit
) )
@Composable @Composable
@@ -69,7 +71,7 @@ fun rememberAnyKernel3State(
val onHorizonKernelSelected: (InstallMethod.HorizonKernel) -> Unit = { method -> val onHorizonKernelSelected: (InstallMethod.HorizonKernel) -> Unit = { method ->
val uri = method.uri val uri = method.uri
if (uri != null) { if (uri != null) {
if (isAbDevice) { if (isAbDevice && method.slot == null) {
tempKernelUri = uri tempKernelUri = uri
showSlotSelectionDialog = true showSlotSelectionDialog = true
} else { } else {
@@ -79,8 +81,21 @@ 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 onSlotSelected: (String) -> Unit = { slot ->
val uri = tempKernelUri val uri = tempKernelUri ?: (installMethodState.value as? InstallMethod.HorizonKernel)?.uri
if (uri != null) { if (uri != null) {
installMethodState.value = InstallMethod.HorizonKernel( installMethodState.value = InstallMethod.HorizonKernel(
uri = uri, uri = uri,
@@ -95,7 +110,6 @@ fun rememberAnyKernel3State(
val onDismissSlotDialog = { val onDismissSlotDialog = {
showSlotSelectionDialog = false showSlotSelectionDialog = false
tempKernelUri = null
} }
val onOptionSelected: (KpmPatchOption) -> Unit = { option -> val onOptionSelected: (KpmPatchOption) -> Unit = { option ->
@@ -135,7 +149,9 @@ fun rememberAnyKernel3State(
onSlotSelected = onSlotSelected, onSlotSelected = onSlotSelected,
onDismissSlotDialog = onDismissSlotDialog, onDismissSlotDialog = onDismissSlotDialog,
onOptionSelected = onOptionSelected, onOptionSelected = onOptionSelected,
onDismissPatchDialog = onDismissPatchDialog onDismissPatchDialog = onDismissPatchDialog,
onReopenSlotDialog = onReopenSlotDialog,
onReopenKpmDialog = onReopenKpmDialog
) )
} }

View File

@@ -1,15 +1,14 @@
package com.sukisu.ultra.ui.kernelFlash package com.sukisu.ultra.ui.kernelFlash
import android.content.Context import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
@@ -19,7 +18,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.platform.LocalContext 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator 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.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton 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.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Save 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.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.scrollEndHaptic import top.yukonga.miuix.kmp.utils.scrollEndHaptic
import java.io.File import java.io.File
@@ -66,6 +65,15 @@ private object KernelFlashStateHolder {
var currentKpmPatchEnabled: Boolean = false var currentKpmPatchEnabled: Boolean = false
var currentKpmUndoPatch: Boolean = false var currentKpmUndoPatch: Boolean = false
var isFlashing = false var isFlashing = false
fun clear() {
currentState = null
currentUri = null
currentSlot = null
currentKpmPatchEnabled = false
currentKpmUndoPatch = false
isFlashing = false
}
} }
@Destination<RootGraph> @Destination<RootGraph>
@@ -78,12 +86,6 @@ fun KernelFlashScreen(
kpmUndoPatch: Boolean = false kpmUndoPatch: Boolean = false
) { ) {
val context = LocalContext.current 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 scrollState = rememberScrollState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var logText by rememberSaveable { mutableStateOf("") } var logText by rememberSaveable { mutableStateOf("") }
@@ -109,17 +111,27 @@ fun KernelFlashScreen(
} }
val flashState by horizonKernelState.state.collectAsState() val flashState by horizonKernelState.state.collectAsState()
val activity = LocalActivity.current
val onFlashComplete = { val onFlashComplete = {
showFloatAction = true showFloatAction = true
KernelFlashStateHolder.isFlashing = false KernelFlashStateHolder.isFlashing = false
}
if (shouldAutoExit) { // 如果是从外部打开的内核刷写延迟1.5秒后自动退出
scope.launch { 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 (isFromExternalIntent) {
delay(1500) delay(1500)
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) KernelFlashStateHolder.clear()
sharedPref.edit { remove("auto_exit_after_flash") } activity.finish()
(context as? ComponentActivity)?.finish()
} }
} }
} }
@@ -170,26 +182,17 @@ fun KernelFlashScreen(
val onBack: () -> Unit = { val onBack: () -> Unit = {
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) { if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
if (flashState.isCompleted || flashState.error.isNotEmpty()) { if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null KernelFlashStateHolder.clear()
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
} }
navigator.popBackStack() navigator.popBackStack()
} }
} }
DisposableEffect(shouldAutoExit) { // 清理状态
DisposableEffect(Unit) {
onDispose { onDispose {
if (shouldAutoExit) { if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null KernelFlashStateHolder.clear()
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
} }
} }
} }
@@ -274,14 +277,14 @@ private fun FlashProgressIndicator(
kpmPatchEnabled: Boolean = false, kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false kpmUndoPatch: Boolean = false
) { ) {
val progressColor = when { val statusColor = when {
flashState.error.isNotEmpty() -> colorScheme.error flashState.error.isNotEmpty() -> colorScheme.error
flashState.isCompleted -> colorScheme.secondary flashState.isCompleted -> colorScheme.primary
else -> colorScheme.primary else -> colorScheme.primary
} }
val progress = animateFloatAsState( val progress = animateFloatAsState(
targetValue = flashState.progress, targetValue = flashState.progress.coerceIn(0f, 1f),
label = "FlashProgress" label = "FlashProgress"
) )
@@ -306,8 +309,9 @@ private fun FlashProgressIndicator(
flashState.isCompleted -> stringResource(R.string.flash_success) flashState.isCompleted -> stringResource(R.string.flash_success)
else -> stringResource(R.string.flashing) else -> stringResource(R.string.flashing)
}, },
fontWeight = FontWeight.Bold, fontSize = MiuixTheme.textStyles.title4.fontSize,
color = progressColor fontWeight = FontWeight.Medium,
color = statusColor
) )
when { when {
@@ -322,7 +326,7 @@ private fun FlashProgressIndicator(
Icon( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
tint = colorScheme.secondary tint = colorScheme.primary
) )
} }
} }
@@ -330,67 +334,44 @@ private fun FlashProgressIndicator(
// KPM状态显示 // KPM状态显示
if (kpmPatchEnabled || kpmUndoPatch) { if (kpmPatchEnabled || kpmUndoPatch) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode) text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
else stringResource(R.string.kpm_patch_mode), else stringResource(R.string.kpm_patch_mode),
color = colorScheme.secondary fontSize = MiuixTheme.textStyles.body2.fontSize,
)
}
Spacer(modifier = Modifier.height(8.dp))
if (flashState.currentStep.isNotEmpty()) {
Text(
text = flashState.currentStep,
color = colorScheme.onSurfaceVariantSummary color = colorScheme.onSurfaceVariantSummary
) )
Spacer(modifier = Modifier.height(8.dp))
} }
val progressFraction = progress.value.coerceIn(0f, 1f) if (flashState.currentStep.isNotEmpty()) {
Box( Spacer(modifier = Modifier.height(12.dp))
modifier = Modifier Text(
.fillMaxWidth() text = flashState.currentStep,
.height(8.dp) fontSize = MiuixTheme.textStyles.body2.fontSize,
.clip(RoundedCornerShape(999.dp)) color = colorScheme.onSurfaceVariantSummary
.background(colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progressFraction)
.clip(RoundedCornerShape(999.dp))
.background(progressColor)
) )
} }
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator(
progress = progress.value,
modifier = Modifier.fillMaxWidth()
)
if (flashState.error.isNotEmpty()) { if (flashState.error.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.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))
Text( Text(
text = flashState.error, text = flashState.error,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onErrorContainer, color = colorScheme.onErrorContainer,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp)
.background( .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.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R 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.Icon
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
@@ -35,11 +40,13 @@ fun SlotSelectionDialog(
var selectedSlot by remember { mutableStateOf<String?>(null) } var selectedSlot by remember { mutableStateOf<String?>(null) }
val showDialog = remember { mutableStateOf(show) } val showDialog = remember { mutableStateOf(show) }
val context = LocalContext.current
LaunchedEffect(show) { LaunchedEffect(show) {
showDialog.value = show showDialog.value = show
if (show) { if (show) {
try { try {
currentSlot = getCurrentSlot() currentSlot = withContext(Dispatchers.IO) { getCurrentSlot() }
// 设置默认选择为当前槽位 // 设置默认选择为当前槽位
selectedSlot = when (currentSlot) { selectedSlot = when (currentSlot) {
"a" -> "a" "a" -> "a"
@@ -48,7 +55,7 @@ fun SlotSelectionDialog(
} }
errorMessage = null errorMessage = null
} catch (e: Exception) { } catch (e: Exception) {
errorMessage = e.message errorMessage = context.getString(R.string.operation_failed)
currentSlot = null currentSlot = null
} }
} }
@@ -85,9 +92,9 @@ fun SlotSelectionDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 8.dp),
text = "Error: $errorMessage", text = errorMessage ?: context.getString(R.string.operation_failed),
fontSize = MiuixTheme.textStyles.body2.fontSize, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.primary, color = colorScheme.error,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} else { } else {
@@ -97,7 +104,7 @@ fun SlotSelectionDialog(
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource( text = stringResource(
id = R.string.current_slot, id = R.string.current_slot,
currentSlot ?: "Unknown" currentSlot?.uppercase() ?: context.getString(R.string.not_supported)
), ),
fontSize = MiuixTheme.textStyles.body2.fontSize, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary, color = colorScheme.onSurfaceVariantSummary,
@@ -194,25 +201,16 @@ data class SlotOption(
) )
// Utility function to get current slot // Utility function to get current slot
private fun getCurrentSlot(): String? { private suspend 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 { return try {
val process = ProcessBuilder("su").start() val shell = getRootShell()
process.outputStream.bufferedWriter().use { writer -> val result = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
writer.write("$cmd\n") if (result.startsWith("_")) {
writer.write("exit\n") result.substring(1)
writer.flush() } else {
} result
process.inputStream.bufferedReader().use { reader -> }.takeIf { it.isNotEmpty() }
reader.readText().trim() } catch (e: Exception) {
}
} catch (_: Exception) {
null null
} }
} }

View File

@@ -8,9 +8,10 @@ import androidx.documentfile.provider.DocumentFile
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil
import com.sukisu.ultra.ui.kernelFlash.util.RemoteToolsDownloader 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.install
import com.sukisu.ultra.ui.util.rootAvailable import com.sukisu.ultra.ui.util.rootAvailable
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -74,10 +75,6 @@ class HorizonKernelState {
fun completeFlashing() { fun completeFlashing() {
_state.update { it.copy(isCompleted = true, progress = 1f) } _state.update { it.copy(isCompleted = true, progress = 1f) }
} }
fun reset() {
_state.value = FlashState()
}
} }
class HorizonKernelWorker( class HorizonKernelWorker(
@@ -157,7 +154,12 @@ class HorizonKernelWorker(
if (isAbDevice && slot != null) { if (isAbDevice && slot != null) {
state.updateStep(context.getString(R.string.horizon_getting_original_slot)) state.updateStep(context.getString(R.string.horizon_getting_original_slot))
state.updateProgress(0.72f) 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.updateStep(context.getString(R.string.horizon_setting_target_slot))
state.updateProgress(0.74f) state.updateProgress(0.74f)
@@ -308,7 +310,12 @@ class HorizonKernelWorker(
} }
// 查找Image文件 // 查找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()) { if (findImageResult.isBlank()) {
throw IOException(context.getString(R.string.kpm_image_file_not_found)) throw IOException(context.getString(R.string.kpm_image_file_not_found))
} }
@@ -398,11 +405,16 @@ class HorizonKernelWorker(
// 检查设备是否为AB分区设备 // 检查设备是否为AB分区设备
private fun isAbDevice(): Boolean { private fun isAbDevice(): Boolean {
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update") return try {
if (!abUpdate.toBoolean()) return false 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") val slotSuffix = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
return slotSuffix.isNotEmpty() slotSuffix.isNotEmpty()
} catch (_: Exception) {
false
}
} }
private fun cleanup() { private fun cleanup() {
@@ -429,7 +441,12 @@ class HorizonKernelWorker(
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid")
private fun patch() { 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 versionRegex = """\d+\.\d+\.\d+""".toRegex()
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: "" val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
val toolName = if (version.isNotEmpty()) { val toolName = if (version.isNotEmpty()) {
@@ -447,7 +464,9 @@ class HorizonKernelWorker(
val toolPath = "${context.filesDir.absolutePath}/mkbootfs" val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath) AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}") 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() { private fun flash() {
@@ -517,8 +536,4 @@ class HorizonKernelWorker(
process.destroy() 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.content.Context
import android.util.Log import android.util.Log
import com.sukisu.ultra.ui.util.getRootShell
import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.io.FileOutputStream 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 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 CONNECTION_TIMEOUT = 10000
private const val READ_TIMEOUT = 30000 // 30秒读取超时 private const val READ_TIMEOUT = 20000
// 最大重试次数 // 最大重试次数
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 3
@@ -48,47 +50,26 @@ class RemoteToolsDownloader(
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) { suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, DownloadResult>()
listener?.onLog("Starting to prepare KPM tool files...") listener?.onLog("Starting to prepare KPM tool files...")
File(workDir).mkdirs()
try { // 并行下载两个工具文件
// 确保工作目录存在 val results = mapOf(
File(workDir).mkdirs() "kptools" to async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) },
"kpimg" to async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
).mapValues { it.value.await() }
// 并行下载两个工具文件 // 设置 kptools 执行权限
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) } File(workDir, "kptools").takeIf { it.exists() }?.let { file ->
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) } setExecutablePermission(file.absolutePath)
listener?.onLog("Set kptools execution permission")
// 等待所有下载完成
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() 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( private suspend fun downloadSingleTool(
@@ -96,43 +77,38 @@ class RemoteToolsDownloader(
remoteUrl: String?, remoteUrl: String?,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
val targetFile = File(workDir, fileName) val targetFile = File(workDir, fileName)
if (remoteUrl == null) { if (remoteUrl == null) {
return@withContext useLocalVersion(fileName, targetFile, listener) return@withContext useLocalVersion(fileName, targetFile, listener)
} }
// 尝试从远程下载
listener?.onLog("Downloading $fileName from remote repository...") listener?.onLog("Downloading $fileName from remote repository...")
var lastError = ""
// 重试机制 // 重试机制
var lastError = ""
repeat(MAX_RETRY_COUNT) { attempt -> repeat(MAX_RETRY_COUNT) { attempt ->
try { try {
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener) val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
if (result.success) { if (result.success) {
listener?.onSuccess(fileName, true) listener?.onSuccess(fileName, true)
return@withContext result return@withContext result
} else {
lastError = result.errorMessage ?: "Unknown error"
} }
lastError = result.errorMessage ?: "Unknown error"
} catch (e: Exception) { } catch (e: Exception) {
lastError = e.message ?: "Network exception" lastError = "Network exception"
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e) Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
}
if (attempt < MAX_RETRY_COUNT - 1) { if (attempt < MAX_RETRY_COUNT - 1) {
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...") listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L)) delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
}
} }
} }
// 所有重试都失败,回退到本地版本
listener?.onError(fileName, "Remote download failed: $lastError") listener?.onError(fileName, "Remote download failed: $lastError")
listener?.onLog("$fileName remote download failed, falling back to local version...") listener?.onLog("$fileName remote download failed, falling back to local version...")
useLocalVersion(fileName, targetFile, listener) useLocalVersion(fileName, targetFile, listener)
} }
@@ -142,15 +118,10 @@ class RemoteToolsDownloader(
targetFile: File, targetFile: File,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
try { try {
val url = URL(remoteUrl) connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply {
connection = url.openConnection() as HttpURLConnection
// 设置连接参数
connection.apply {
connectTimeout = CONNECTION_TIMEOUT connectTimeout = CONNECTION_TIMEOUT
readTimeout = READ_TIMEOUT readTimeout = READ_TIMEOUT
requestMethod = "GET" requestMethod = "GET"
@@ -159,22 +130,17 @@ class RemoteToolsDownloader(
setRequestProperty("Connection", "close") setRequestProperty("Connection", "close")
} }
// 建立连接
connection.connect() connection.connect()
val responseCode = connection.responseCode if (connection.responseCode != HttpURLConnection.HTTP_OK) {
if (responseCode != HttpURLConnection.HTTP_OK) {
return@withContext DownloadResult( return@withContext DownloadResult(
false, false,
isRemoteSource = false, isRemoteSource = false,
errorMessage = "HTTP error code: $responseCode" errorMessage = "HTTP error code: ${connection.responseCode}"
) )
} }
val fileLength = connection.contentLength val fileLength = connection.contentLength
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
// 创建临时文件
val tempFile = File(targetFile.absolutePath + ".tmp") val tempFile = File(targetFile.absolutePath + ".tmp")
// 下载文件 // 下载文件
@@ -182,40 +148,34 @@ class RemoteToolsDownloader(
FileOutputStream(tempFile).use { output -> FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
var totalBytes = 0 var totalBytes = 0
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) { while (true) {
// 检查协程是否被取消
ensureActive() ensureActive()
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead) output.write(buffer, 0, bytesRead)
totalBytes += bytesRead totalBytes += bytesRead
// 更新下载进度
if (fileLength > 0) { if (fileLength > 0) {
listener?.onProgress(fileName, totalBytes, fileLength) listener?.onProgress(fileName, totalBytes, fileLength)
} }
} }
output.flush() output.flush()
} }
} }
// 验证下载的文件 // 验证并移动文件
if (!validateDownloadedFile(tempFile, fileName)) { if (!validateDownloadedFile(tempFile, fileName)) {
tempFile.delete() tempFile.delete()
return@withContext DownloadResult( return@withContext DownloadResult(
success = false, false,
isRemoteSource = false, isRemoteSource = false,
errorMessage = "File verification failed" errorMessage = "File verification failed"
) )
} }
// 移动临时文件到目标位置 targetFile.delete()
if (targetFile.exists()) {
targetFile.delete()
}
if (!tempFile.renameTo(targetFile)) { if (!tempFile.renameTo(targetFile)) {
tempFile.delete() tempFile.delete()
return@withContext DownloadResult( return@withContext DownloadResult(
@@ -227,7 +187,6 @@ class RemoteToolsDownloader(
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes") Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName remote download successful") listener?.onLog("$fileName remote download successful")
DownloadResult(true, isRemoteSource = true) DownloadResult(true, isRemoteSource = true)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
@@ -235,16 +194,10 @@ class RemoteToolsDownloader(
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout") DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "$fileName network IO exception", e) Log.w(TAG, "$fileName network IO exception", e)
DownloadResult(false, DownloadResult(false, isRemoteSource = false, errorMessage = "Network exception: ${e.message}")
isRemoteSource = false,
errorMessage = "Network connection exception: ${e.message}"
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "$fileName exception occurred during download", e) Log.e(TAG, "$fileName exception occurred during download", e)
DownloadResult(false, DownloadResult(false, isRemoteSource = false, errorMessage = "Download exception: ${e.message}")
isRemoteSource = false,
errorMessage = "Download exception: ${e.message}"
)
} finally { } finally {
connection?.disconnect() connection?.disconnect()
} }
@@ -255,61 +208,42 @@ class RemoteToolsDownloader(
targetFile: File, targetFile: File,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
try { try {
AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath) AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
if (!targetFile.exists()) { if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) {
val errorMsg = "Local $fileName file extraction failed" val errorMsg = if (!targetFile.exists()) {
"Local $fileName file extraction failed"
} else {
"Local $fileName file verification failed"
}
listener?.onError(fileName, errorMsg) listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(false, return@withContext DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
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") Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName local version loaded successfully") listener?.onLog("$fileName local version loaded successfully")
listener?.onSuccess(fileName, false) listener?.onSuccess(fileName, false)
DownloadResult(true, isRemoteSource = false) DownloadResult(true, isRemoteSource = false)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "$fileName local version loading failed", e) Log.e(TAG, "$fileName local version loading failed", e)
val errorMsg = "Local version loading failed: ${e.message}" val errorMsg = "Local version loading failed: ${e.message}"
listener?.onError(fileName, errorMsg) 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 { private fun validateDownloadedFile(file: File, fileName: String): Boolean {
if (!file.exists()) { if (!file.exists() || file.length() < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file does not exist") Log.w(TAG, "$fileName file validation failed: exists=${file.exists()}, size=${file.length()}")
return false return false
} }
val fileSize = file.length() return try {
if (fileSize < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
return false
}
try {
file.inputStream().use { input -> file.inputStream().use { input ->
val header = ByteArray(4) val header = ByteArray(4)
val bytesRead = input.read(header) if (input.read(header) < 4) {
if (bytesRead < 4) {
Log.w(TAG, "$fileName file header read incomplete") Log.w(TAG, "$fileName file header read incomplete")
return false return false
} }
@@ -324,20 +258,24 @@ class RemoteToolsDownloader(
return false return false
} }
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF") Log.d(TAG, "$fileName file verification passed, size: ${file.length()} bytes, ELF: $isELF")
return true true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "$fileName file verification exception", e) Log.w(TAG, "$fileName file verification exception", e)
return false false
} }
} }
private fun setExecutablePermission(filePath: String) { private fun setExecutablePermission(filePath: String) {
try { try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath")) val shell = getRootShell()
process.waitFor() if (ShellUtils.fastCmdResult(shell, "chmod a+rx $filePath")) {
Log.d(TAG, "Set execution permission for $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) { } catch (e: Exception) {
Log.w(TAG, "Failed to set execution permission: $filePath", e) Log.w(TAG, "Failed to set execution permission: $filePath", e)
try { try {
@@ -351,11 +289,9 @@ class RemoteToolsDownloader(
fun cleanup() { fun cleanup() {
try { try {
File(workDir).listFiles()?.forEach { file -> File(workDir).listFiles()?.filter { it.name.endsWith(".tmp") }?.forEach { file ->
if (file.name.endsWith(".tmp")) { file.delete()
file.delete() Log.d(TAG, "Cleaned temporary file: ${file.name}")
Log.d(TAG, "Cleaned temporary file: ${file.name}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to clean temporary files", e) Log.w(TAG, "Failed to clean temporary files", e)

View File

@@ -1,5 +1,6 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.Parcelable import android.os.Parcelable
@@ -37,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
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.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.compose.dropUnlessResumed
import kotlinx.coroutines.delay
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -115,6 +118,7 @@ fun FlashScreen(
var showFloatAction by rememberSaveable { mutableStateOf(false) } var showFloatAction by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val activity = LocalActivity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var flashing by rememberSaveable { 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( Scaffold(
topBar = { topBar = {
TopBar( TopBar(

View File

@@ -362,7 +362,7 @@ fun InstallScreen(
// AnyKernel3 刷写 // AnyKernel3 刷写
(installMethod as? InstallMethod.HorizonKernel)?.let { method -> (installMethod as? InstallMethod.HorizonKernel)?.let { method ->
if (method.slot != null) { if (isAbDevice && method.slot != null) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -374,11 +374,13 @@ fun InstallScreen(
if (method.slot == "a") stringResource(id = R.string.slot_a) if (method.slot == "a") stringResource(id = R.string.slot_a)
else stringResource(id = R.string.slot_b) else stringResource(id = R.string.slot_b)
), ),
onClick = {}, onClick = {
anyKernel3State.onReopenSlotDialog(method)
},
leftAction = { leftAction = {
Icon( Icon(
Icons.Filled.SdStorage, Icons.Filled.SdStorage,
tint = colorScheme.onSurface, tint = colorScheme.primary,
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.padding(end = 16.dp),
contentDescription = null contentDescription = null
) )
@@ -388,32 +390,33 @@ fun InstallScreen(
} }
// KPM 状态显示 // KPM 状态显示
if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) { Card(
Card( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(top = 12.dp),
.padding(top = 12.dp), ) {
) { SuperArrow(
SuperArrow( title = when (kpmPatchOption) {
title = when (kpmPatchOption) { KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled)
KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled) KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled)
KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled) KpmPatchOption.FOLLOW_KERNEL -> stringResource(R.string.kpm_follow_kernel_file)
else -> "" },
}, onClick = {
onClick = {}, anyKernel3State.onReopenKpmDialog(method)
leftAction = { },
Icon( leftAction = {
Icons.Filled.Security, Icon(
tint = if (kpmPatchOption == KpmPatchOption.PATCH_KPM) Icons.Filled.Security,
colorScheme.primary tint = when (kpmPatchOption) {
else KpmPatchOption.PATCH_KPM -> colorScheme.primary
colorScheme.secondary, KpmPatchOption.UNDO_PATCH_KPM -> colorScheme.secondary
modifier = Modifier.padding(end = 16.dp), KpmPatchOption.FOLLOW_KERNEL -> colorScheme.onSurfaceVariantSummary
contentDescription = null },
) modifier = Modifier.padding(end = 16.dp),
} contentDescription = null
) )
} }
)
} }
} }
Button( Button(

View File

@@ -368,4 +368,12 @@
<string name="allowlist_restore_summary_picker">选择备份文件进行导入</string> <string name="allowlist_restore_summary_picker">选择备份文件进行导入</string>
<string name="allowlist_restore_success">还原成功,重启生效</string> <string name="allowlist_restore_success">还原成功,重启生效</string>
<string name="allowlist_restore_failed">还原失败</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> </resources>

View File

@@ -376,4 +376,12 @@
<string name="allowlist_restore_summary_picker">Choose a backup file to import</string> <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_success">Restore succeeded</string>
<string name="allowlist_restore_failed">Restore failed</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> </resources>