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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user