Step 4: Add KPM interface and flash anykernel3

This commit is contained in:
ShirkNeko
2025-11-20 03:18:13 +08:00
parent 9574409955
commit d2a6fa4513
19 changed files with 3386 additions and 65 deletions

View File

@@ -116,6 +116,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.documentfile)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -20,8 +20,13 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.sukisu.ultra.ui.util.getKpmVersion
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -39,6 +44,7 @@ import kotlinx.coroutines.launch
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.component.BottomBar import com.sukisu.ultra.ui.component.BottomBar
import com.sukisu.ultra.ui.screen.HomePager import com.sukisu.ultra.ui.screen.HomePager
import com.sukisu.ultra.ui.screen.KpmScreen
import com.sukisu.ultra.ui.screen.ModulePager import com.sukisu.ultra.ui.screen.ModulePager
import com.sukisu.ultra.ui.screen.SettingPager import com.sukisu.ultra.ui.screen.SettingPager
import com.sukisu.ultra.ui.screen.SuperUserPager import com.sukisu.ultra.ui.screen.SuperUserPager
@@ -120,7 +126,22 @@ val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle
fun MainScreen(navController: DestinationsNavigator) { fun MainScreen(navController: DestinationsNavigator) {
val activity = LocalActivity.current val activity = LocalActivity.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 })
// 检查 KPM 版本是否可用
val kpmVersion by produceState(initialValue = "") {
value = withContext(Dispatchers.IO) {
try {
getKpmVersion()
} catch (e: Exception) {
""
}
}
}
val isKpmAvailable = kpmVersion.isNotEmpty() && !kpmVersion.contains("Error", ignoreCase = true)
val pageCount = if (isKpmAvailable) 5 else 4
val pagerState = rememberPagerState(initialPage = 0, pageCount = { pageCount })
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle( val hazeStyle = HazeStyle(
backgroundColor = MiuixTheme.colorScheme.background, backgroundColor = MiuixTheme.colorScheme.background,
@@ -148,7 +169,7 @@ fun MainScreen(navController: DestinationsNavigator) {
) { ) {
Scaffold( Scaffold(
bottomBar = { bottomBar = {
BottomBar(hazeState, hazeStyle) BottomBar(hazeState, hazeStyle, isKpmAvailable)
}, },
) { innerPadding -> ) { innerPadding ->
HorizontalPager( HorizontalPager(
@@ -157,11 +178,24 @@ fun MainScreen(navController: DestinationsNavigator) {
beyondViewportPageCount = 2, beyondViewportPageCount = 2,
userScrollEnabled = false userScrollEnabled = false
) { ) {
when (it) { when {
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding()) isKpmAvailable -> {
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding()) when (it) {
2 -> ModulePager(navController, innerPadding.calculateBottomPadding()) 0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
3 -> SettingPager(navController, innerPadding.calculateBottomPadding()) 1 -> KpmScreen(bottomInnerPadding = innerPadding.calculateBottomPadding())
2 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
3 -> ModulePager(navController, innerPadding.calculateBottomPadding())
4 -> SettingPager(navController, innerPadding.calculateBottomPadding())
}
}
else -> {
when (it) {
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
}
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package com.sukisu.ultra.ui.component
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Cottage import androidx.compose.material.icons.rounded.Cottage
import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Security import androidx.compose.material.icons.rounded.Security
@@ -27,7 +28,8 @@ import top.yukonga.miuix.kmp.basic.NavigationItem
@Composable @Composable
fun BottomBar( fun BottomBar(
hazeState: HazeState, hazeState: HazeState,
hazeStyle: HazeStyle hazeStyle: HazeStyle,
isKpmAvailable: Boolean = false
) { ) {
val isManager = Natives.isManager val isManager = Natives.isManager
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable() val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
@@ -37,12 +39,24 @@ fun BottomBar(
if (!fullFeatured) return if (!fullFeatured) return
val item = BottomBarDestination.entries.mapIndexed { index, destination -> val destinations = if (isKpmAvailable) {
BottomBarDestination.entries
} else {
BottomBarDestination.entries.filter { it != BottomBarDestination.KPM }
}
val item = destinations.mapIndexed { index, destination ->
NavigationItem( NavigationItem(
label = stringResource(destination.label), label = stringResource(destination.label),
icon = destination.icon, icon = destination.icon,
) )
} }
val bottomBarIndex = if (!isKpmAvailable) {
page.coerceIn(0, item.size - 1)
} else {
page.coerceIn(0, item.size - 1)
}
NavigationBar( NavigationBar(
modifier = Modifier modifier = Modifier
@@ -53,8 +67,10 @@ fun BottomBar(
}, },
color = Color.Transparent, color = Color.Transparent,
items = item, items = item,
selected = page, selected = bottomBarIndex,
onClick = handlePageChange onClick = { index ->
handlePageChange(index)
}
) )
} }
@@ -63,6 +79,7 @@ enum class BottomBarDestination(
val icon: ImageVector, val icon: ImageVector,
) { ) {
Home(R.string.home, Icons.Rounded.Cottage), Home(R.string.home, Icons.Rounded.Cottage),
KPM(R.string.kpm_title, Icons.Rounded.Code),
SuperUser(R.string.superuser, Icons.Rounded.Security), SuperUser(R.string.superuser, Icons.Rounded.Security),
Module(R.string.module, Icons.Rounded.Extension), Module(R.string.module, Icons.Rounded.Extension),
Setting(R.string.settings, Icons.Rounded.Settings) Setting(R.string.settings, Icons.Rounded.Settings)

View File

@@ -0,0 +1,433 @@
package com.sukisu.ultra.ui.kernelFlash
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.util.reboot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.sukisu.ultra.ui.kernelFlash.state.*
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Save
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
private object KernelFlashStateHolder {
var currentState: HorizonKernelState? = null
var currentUri: Uri? = null
var currentSlot: String? = null
var currentKpmPatchEnabled: Boolean = false
var currentKpmUndoPatch: Boolean = false
var isFlashing = false
}
/**
* Kernel刷写界面
*/
@Destination<RootGraph>
@Composable
fun KernelFlashScreen(
navigator: DestinationsNavigator,
kernelUri: Uri,
selectedSlot: String? = null,
kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false
) {
val context = LocalContext.current
val shouldAutoExit = remember {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.getBoolean("auto_exit_after_flash", false)
}
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var logText by rememberSaveable { mutableStateOf("") }
var showFloatAction by rememberSaveable { mutableStateOf(false) }
val logContent = rememberSaveable { StringBuilder() }
val horizonKernelState = remember {
if (KernelFlashStateHolder.currentState != null &&
KernelFlashStateHolder.currentUri == kernelUri &&
KernelFlashStateHolder.currentSlot == selectedSlot &&
KernelFlashStateHolder.currentKpmPatchEnabled == kpmPatchEnabled &&
KernelFlashStateHolder.currentKpmUndoPatch == kpmUndoPatch) {
KernelFlashStateHolder.currentState!!
} else {
HorizonKernelState().also {
KernelFlashStateHolder.currentState = it
KernelFlashStateHolder.currentUri = kernelUri
KernelFlashStateHolder.currentSlot = selectedSlot
KernelFlashStateHolder.currentKpmPatchEnabled = kpmPatchEnabled
KernelFlashStateHolder.currentKpmUndoPatch = kpmUndoPatch
KernelFlashStateHolder.isFlashing = false
}
}
}
val flashState by horizonKernelState.state.collectAsState()
val onFlashComplete = {
showFloatAction = true
KernelFlashStateHolder.isFlashing = false
// 如果需要自动退出延迟1.5秒后退出
if (shouldAutoExit) {
scope.launch {
delay(1500)
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.edit { remove("auto_exit_after_flash") }
(context as? ComponentActivity)?.finish()
}
}
}
// 开始刷写
LaunchedEffect(Unit) {
if (!KernelFlashStateHolder.isFlashing && !flashState.isCompleted && flashState.error.isEmpty()) {
withContext(Dispatchers.IO) {
KernelFlashStateHolder.isFlashing = true
val worker = HorizonKernelWorker(
context = context,
state = horizonKernelState,
slot = selectedSlot,
kpmPatchEnabled = kpmPatchEnabled,
kpmUndoPatch = kpmUndoPatch
)
worker.uri = kernelUri
worker.setOnFlashCompleteListener(onFlashComplete)
worker.start()
// 监听日志更新
while (flashState.error.isEmpty()) {
if (flashState.logs.isNotEmpty()) {
logText = flashState.logs.joinToString("\n")
logContent.clear()
logContent.append(logText)
}
delay(100)
}
if (flashState.error.isNotEmpty()) {
logText += "\n${flashState.error}\n"
logContent.append("\n${flashState.error}\n")
KernelFlashStateHolder.isFlashing = false
}
}
} else {
logText = flashState.logs.joinToString("\n")
if (flashState.error.isNotEmpty()) {
logText += "\n${flashState.error}\n"
} else if (flashState.isCompleted) {
logText += "\n${context.getString(R.string.horizon_flash_complete)}\n\n\n"
showFloatAction = true
}
}
}
val onBack: () -> Unit = {
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
// 清理全局状态
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
}
navigator.popBackStack()
}
}
DisposableEffect(shouldAutoExit) {
onDispose {
if (shouldAutoExit) {
KernelFlashStateHolder.currentState = null
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
}
}
}
BackHandler {
onBack()
}
KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp
}
Scaffold(
topBar = {
TopBar(
flashState = flashState,
onBack = onBack,
onSave = {
scope.launch {
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val date = format.format(Date())
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"KernelSU_kernel_flash_log_${date}.log"
)
file.writeText(logContent.toString())
}
}
)
},
floatingActionButton = {
if (showFloatAction) {
FloatingActionButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) {
reboot()
}
}
},
modifier = Modifier.padding(bottom = 20.dp, end = 20.dp)
) {
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(id = R.string.reboot)
)
}
}
},
popupHost = { }
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.scrollEndHaptic(),
) {
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(scrollState)
) {
LaunchedEffect(logText) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Text(
modifier = Modifier.padding(16.dp),
text = logText,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
)
}
}
}
}
@Composable
private fun FlashProgressIndicator(
flashState: FlashState,
kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false
) {
val progressColor = when {
flashState.error.isNotEmpty() -> colorScheme.primary
flashState.isCompleted -> colorScheme.secondary
else -> colorScheme.primary
}
val progress = animateFloatAsState(
targetValue = flashState.progress,
label = "FlashProgress"
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = when {
flashState.error.isNotEmpty() -> stringResource(R.string.flash_failed)
flashState.isCompleted -> stringResource(R.string.flash_success)
else -> stringResource(R.string.flashing)
},
fontWeight = FontWeight.Bold,
color = progressColor
)
when {
flashState.error.isNotEmpty() -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = colorScheme.primary
)
}
flashState.isCompleted -> {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = colorScheme.secondary
)
}
}
}
// KPM状态显示
if (kpmPatchEnabled || kpmUndoPatch) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
else stringResource(R.string.kpm_patch_mode),
color = colorScheme.secondary
)
}
Spacer(modifier = Modifier.height(8.dp))
if (flashState.currentStep.isNotEmpty()) {
Text(
text = flashState.currentStep,
color = colorScheme.onSurfaceVariantSummary
)
Spacer(modifier = Modifier.height(8.dp))
}
LinearProgressIndicator(
progress = progress.value,
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
)
if (flashState.error.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = flashState.error,
color = colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.background(
colorScheme.primaryContainer.copy(alpha = 0.3f)
)
.padding(8.dp)
)
}
}
}
}
@Composable
private fun TopBar(
flashState: FlashState,
onBack: () -> Unit,
onSave: () -> Unit = {}
) {
SmallTopAppBar(
title = stringResource(
when {
flashState.error.isNotEmpty() -> R.string.flash_failed
flashState.isCompleted -> R.string.flash_success
else -> R.string.kernel_flashing
}
),
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onBackground
)
}
},
actions = {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSave
) {
Icon(
imageVector = MiuixIcons.Useful.Save,
contentDescription = stringResource(id = R.string.save_log),
tint = colorScheme.onBackground
)
}
}
)
}

View File

@@ -0,0 +1,218 @@
package com.sukisu.ultra.ui.kernelFlash.component
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
/**
* 槽位选择对话框组件
* 用于Kernel刷写时选择目标槽位
*/
@Composable
fun SlotSelectionDialog(
show: Boolean,
onDismiss: () -> Unit,
onSlotSelected: (String) -> Unit
) {
var currentSlot by remember { mutableStateOf<String?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var selectedSlot by remember { mutableStateOf<String?>(null) }
val showDialog = remember { mutableStateOf(show) }
LaunchedEffect(show) {
showDialog.value = show
if (show) {
try {
currentSlot = getCurrentSlot()
// 设置默认选择为当前槽位
selectedSlot = when (currentSlot) {
"a" -> "a"
"b" -> "b"
else -> null
}
errorMessage = null
} catch (e: Exception) {
errorMessage = e.message
currentSlot = null
}
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
onDismiss()
},
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
// 标题
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
text = stringResource(id = R.string.select_slot_title),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
// 当前槽位或错误信息
if (errorMessage != null) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = "Error: $errorMessage",
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.primary,
textAlign = TextAlign.Center
)
} else {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(
id = R.string.current_slot,
currentSlot ?: "Unknown"
),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
}
// 描述文本
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(id = R.string.select_slot_description),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
// 槽位选项
val slotOptions = listOf(
SlotOption(
slot = "a",
titleText = stringResource(id = R.string.slot_a),
icon = Icons.Filled.SdStorage
),
SlotOption(
slot = "b",
titleText = stringResource(id = R.string.slot_b),
icon = Icons.Filled.SdStorage
)
)
slotOptions.forEach { option ->
SuperArrow(
title = option.titleText,
leftAction = {
Icon(
imageVector = option.icon,
contentDescription = null,
tint = if (selectedSlot == option.slot) {
colorScheme.primary
} else {
colorScheme.onSurfaceVariantSummary
}
)
},
onClick = {
selectedSlot = option.slot
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
// 按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
selectedSlot?.let { onSlotSelected(it) }
showDialog.value = false
onDismiss()
},
enabled = selectedSlot != null,
modifier = Modifier.weight(1f)
)
}
}
}
)
}
// Data class for slot options
data class SlotOption(
val slot: String,
val titleText: String,
val icon: ImageVector
)
// Utility function to get current slot
private fun getCurrentSlot(): String? {
return runCommandGetOutput()?.let {
if (it.startsWith("_")) it.substring(1) else it
}
}
private fun runCommandGetOutput(): String? {
val cmd = "getprop ro.boot.slot_suffix"
return try {
val process = ProcessBuilder("su").start()
process.outputStream.bufferedWriter().use { writer ->
writer.write("$cmd\n")
writer.write("exit\n")
writer.flush()
}
process.inputStream.bufferedReader().use { reader ->
reader.readText().trim()
}
} catch (_: Exception) {
null
}
}

View File

@@ -0,0 +1,524 @@
package com.sukisu.ultra.ui.kernelFlash.state
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil
import com.sukisu.ultra.ui.kernelFlash.util.RemoteToolsDownloader
import com.sukisu.ultra.ui.util.install
import com.sukisu.ultra.ui.util.rootAvailable
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
data class FlashState(
val isFlashing: Boolean = false,
val isCompleted: Boolean = false,
val progress: Float = 0f,
val currentStep: String = "",
val logs: List<String> = emptyList(),
val error: String = ""
)
class HorizonKernelState {
private val _state = MutableStateFlow(FlashState())
val state: StateFlow<FlashState> = _state.asStateFlow()
fun updateProgress(progress: Float) {
_state.update { it.copy(progress = progress) }
}
fun updateStep(step: String) {
_state.update { it.copy(currentStep = step) }
}
fun addLog(log: String) {
_state.update {
it.copy(logs = it.logs + log)
}
}
fun setError(error: String) {
_state.update { it.copy(error = error) }
}
fun startFlashing() {
_state.update {
it.copy(
isFlashing = true,
isCompleted = false,
progress = 0f,
currentStep = "under preparation...",
logs = emptyList(),
error = ""
)
}
}
fun completeFlashing() {
_state.update { it.copy(isCompleted = true, progress = 1f) }
}
fun reset() {
_state.value = FlashState()
}
}
class HorizonKernelWorker(
private val context: Context,
private val state: HorizonKernelState,
private val slot: String? = null,
private val kpmPatchEnabled: Boolean = false,
private val kpmUndoPatch: Boolean = false
) : Thread() {
var uri: Uri? = null
private lateinit var filePath: String
private lateinit var binaryPath: String
private lateinit var workDir: String
private var onFlashComplete: (() -> Unit)? = null
private var originalSlot: String? = null
private var downloaderJob: Job? = null
fun setOnFlashCompleteListener(listener: () -> Unit) {
onFlashComplete = listener
}
override fun run() {
state.startFlashing()
state.updateStep(context.getString(R.string.horizon_preparing))
filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}"
binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary"
workDir = "${context.filesDir.absolutePath}/work"
try {
state.updateStep(context.getString(R.string.horizon_cleaning_files))
state.updateProgress(0.1f)
cleanup()
if (!rootAvailable()) {
state.setError(context.getString(R.string.root_required))
return
}
state.updateStep(context.getString(R.string.horizon_copying_files))
state.updateProgress(0.2f)
copy()
if (!File(filePath).exists()) {
state.setError(context.getString(R.string.horizon_copy_failed))
return
}
state.updateStep(context.getString(R.string.horizon_extracting_tool))
state.updateProgress(0.4f)
getBinary()
// KPM修补
if (kpmPatchEnabled || kpmUndoPatch) {
state.updateStep(context.getString(R.string.kpm_preparing_tools))
state.updateProgress(0.5f)
prepareKpmToolsWithDownload()
state.updateStep(
if (kpmUndoPatch) context.getString(R.string.kpm_undoing_patch)
else context.getString(R.string.kpm_applying_patch)
)
state.updateProgress(0.55f)
performKpmPatch()
}
state.updateStep(context.getString(R.string.horizon_patching_script))
state.updateProgress(0.6f)
patch()
state.updateStep(context.getString(R.string.horizon_flashing))
state.updateProgress(0.7f)
val isAbDevice = isAbDevice()
if (isAbDevice && slot != null) {
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
state.updateProgress(0.72f)
originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix")
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
state.updateProgress(0.74f)
runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot")
}
flash()
if (isAbDevice && !originalSlot.isNullOrEmpty()) {
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
state.updateProgress(0.8f)
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
}
try {
install()
} catch (e: Exception) {
state.updateStep("ksud update skipped: ${e.message}")
}
state.updateStep(context.getString(R.string.horizon_flash_complete_status))
state.completeFlashing()
(context as? Activity)?.runOnUiThread {
onFlashComplete?.invoke()
}
} catch (e: Exception) {
state.setError(e.message ?: context.getString(R.string.horizon_unknown_error))
if (isAbDevice() && !originalSlot.isNullOrEmpty()) {
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
state.updateProgress(0.8f)
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
}
} finally {
// 取消下载任务并清理
downloaderJob?.cancel()
cleanupDownloader()
}
}
private fun prepareKpmToolsWithDownload() {
try {
File(workDir).mkdirs()
val downloader = RemoteToolsDownloader(context, workDir)
val progressListener = object : RemoteToolsDownloader.DownloadProgressListener {
override fun onProgress(fileName: String, progress: Int, total: Int) {
val percentage = if (total > 0) (progress * 100) / total else 0
state.addLog("Downloading $fileName: $percentage% ($progress/$total bytes)")
}
override fun onLog(message: String) {
state.addLog(message)
}
override fun onError(fileName: String, error: String) {
state.addLog("Warning: $fileName - $error")
}
override fun onSuccess(fileName: String, isRemote: Boolean) {
val source = if (isRemote) "remote" else "local"
state.addLog("$fileName $source version prepared successfully")
}
}
val downloadJob = CoroutineScope(Dispatchers.IO).launch {
downloader.downloadToolsAsync(progressListener)
}
downloaderJob = downloadJob
runBlocking {
downloadJob.join()
}
val kptoolsPath = "$workDir/kptools"
val kpimgPath = "$workDir/kpimg"
if (!File(kptoolsPath).exists()) {
throw IOException("kptools file preparation failed")
}
if (!File(kpimgPath).exists()) {
throw IOException("kpimg file preparation failed")
}
runCommand(true, "chmod a+rx $kptoolsPath")
state.addLog("KPM tools preparation completed, starting patch operation")
} catch (_: CancellationException) {
state.addLog("KPM tools download cancelled")
throw IOException("Tool preparation process interrupted")
} catch (e: Exception) {
state.addLog("KPM tools preparation failed: ${e.message}")
state.addLog("Attempting to use legacy local file extraction...")
try {
prepareKpmToolsLegacy()
state.addLog("Successfully used local backup files")
} catch (legacyException: Exception) {
state.addLog("Local file extraction also failed: ${legacyException.message}")
throw IOException("Unable to prepare KPM tool files: ${e.message}")
}
}
}
private fun prepareKpmToolsLegacy() {
File(workDir).mkdirs()
val kptoolsPath = "$workDir/kptools"
val kpimgPath = "$workDir/kpimg"
AssetsUtil.exportFiles(context, "kptools", kptoolsPath)
if (!File(kptoolsPath).exists()) {
throw IOException("Local kptools file extraction failed")
}
AssetsUtil.exportFiles(context, "kpimg", kpimgPath)
if (!File(kpimgPath).exists()) {
throw IOException("Local kpimg file extraction failed")
}
runCommand(true, "chmod a+rx $kptoolsPath")
}
private fun cleanupDownloader() {
try {
val downloader = RemoteToolsDownloader(context, workDir)
downloader.cleanup()
} catch (_: Exception) {
}
}
/**
* 执行KPM修补操作
*/
private fun performKpmPatch() {
try {
// 创建临时解压目录
val extractDir = "$workDir/extracted"
File(extractDir).mkdirs()
// 解压压缩包到临时目录
val unzipResult = runCommand(true, "cd $extractDir && unzip -o \"$filePath\"")
if (unzipResult != 0) {
throw IOException(context.getString(R.string.kpm_extract_zip_failed))
}
// 查找Image文件
val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f")
if (findImageResult.isBlank()) {
throw IOException(context.getString(R.string.kpm_image_file_not_found))
}
val imageFile = findImageResult.lines().first().trim()
val imageDir = File(imageFile).parent
val imageName = File(imageFile).name
state.addLog(context.getString(R.string.kpm_found_image_file, imageFile))
// 复制KPM工具到Image文件所在目录
runCommand(true, "cp $workDir/kptools $imageDir/")
runCommand(true, "cp $workDir/kpimg $imageDir/")
// 执行KPM修补命令
val patchCommand = if (kpmUndoPatch) {
"cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
} else {
"cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
}
val patchResult = runCommand(true, patchCommand)
if (patchResult != 0) {
throw IOException(
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_failed)
else context.getString(R.string.kpm_patch_failed)
)
}
state.addLog(
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_success)
else context.getString(R.string.kpm_patch_success)
)
// 清理KPM工具文件
runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage")
// 重新打包ZIP文件
val originalFileName = File(filePath).name
val patchedFilePath = "$workDir/patched_$originalFileName"
repackZipFolder(extractDir, patchedFilePath)
// 替换原始文件
runCommand(true, "mv \"$patchedFilePath\" \"$filePath\"")
state.addLog(context.getString(R.string.kpm_file_repacked))
} catch (e: Exception) {
state.addLog(context.getString(R.string.kpm_patch_operation_failed, e.message))
throw e
} finally {
// 清理临时文件
runCommand(true, "rm -rf $workDir")
}
}
private fun repackZipFolder(sourceDir: String, zipFilePath: String) {
try {
val buffer = ByteArray(1024)
val sourceFolder = File(sourceDir)
FileOutputStream(zipFilePath).use { fos ->
ZipOutputStream(fos).use { zos ->
sourceFolder.walkTopDown().forEach { file ->
if (file.isFile) {
val relativePath = file.relativeTo(sourceFolder).path
val zipEntry = ZipEntry(relativePath)
zos.putNextEntry(zipEntry)
file.inputStream().use { fis ->
var length: Int
while (fis.read(buffer).also { length = it } > 0) {
zos.write(buffer, 0, length)
}
}
zos.closeEntry()
}
}
}
}
} catch (e: Exception) {
throw IOException("Failed to create zip file: ${e.message}", e)
}
}
// 检查设备是否为AB分区设备
private fun isAbDevice(): Boolean {
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update")
if (!abUpdate.toBoolean()) return false
val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix")
return slotSuffix.isNotEmpty()
}
private fun cleanup() {
runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete")
runCommand(false, "rm -rf $workDir")
}
private fun copy() {
uri?.let { safeUri ->
context.contentResolver.openInputStream(safeUri)?.use { input ->
FileOutputStream(File(filePath)).use { output ->
input.copyTo(output)
}
}
}
}
private fun getBinary() {
runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}")
if (!File(binaryPath).exists()) {
throw IOException("Failed to extract update-binary")
}
}
@SuppressLint("StringFormatInvalid")
private fun patch() {
val kernelVersion = runCommandGetOutput("cat /proc/version")
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
val toolName = if (version.isNotEmpty()) {
val parts = version.split('.')
if (parts.size >= 2) {
val major = parts[0].toIntOrNull() ?: 0
val minor = parts[1].toIntOrNull() ?: 0
if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+"
} else {
"5_15+"
}
} else {
"5_15+"
}
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath")
}
private fun flash() {
val process = ProcessBuilder("su")
.redirectErrorStream(true)
.start()
try {
process.outputStream.bufferedWriter().use { writer ->
writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n")
// 写入槽位信息到临时文件
slot?.let { selectedSlot ->
writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n")
}
// 构建刷写命令
val flashCommand = buildString {
append("sh $binaryPath 3 1 \"$filePath\"")
if (slot != null) {
append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"")
}
append(" && touch ${context.filesDir.absolutePath}/done\n")
}
writer.write(flashCommand)
writer.write("exit\n")
writer.flush()
}
process.inputStream.bufferedReader().use { reader ->
reader.lineSequence().forEach { line ->
if (line.startsWith("ui_print")) {
val logMessage = line.removePrefix("ui_print").trim()
state.addLog(logMessage)
when {
logMessage.contains("extracting", ignoreCase = true) -> {
state.updateProgress(0.75f)
}
logMessage.contains("installing", ignoreCase = true) -> {
state.updateProgress(0.85f)
}
logMessage.contains("complete", ignoreCase = true) -> {
state.updateProgress(0.95f)
}
}
}
}
}
} finally {
process.destroy()
}
if (!File("${context.filesDir.absolutePath}/done").exists()) {
throw IOException(context.getString(R.string.flash_failed_message))
}
}
private fun runCommand(su: Boolean, cmd: String): Int {
val shell = if (su) "su" else "sh"
val process = Runtime.getRuntime().exec(arrayOf(shell, "-c", cmd))
return try {
process.waitFor()
} finally {
process.destroy()
}
}
private fun runCommandGetOutput(cmd: String): String {
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
}
}

View File

@@ -0,0 +1,26 @@
package com.sukisu.ultra.ui.kernelFlash.util
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object AssetsUtil {
@Throws(IOException::class)
fun exportFiles(context: Context, src: String, out: String) {
val fileNames = context.assets.list(src)
if (fileNames?.isNotEmpty() == true) {
val file = File(out)
file.mkdirs()
fileNames.forEach { fileName ->
exportFiles(context, "$src/$fileName", "$out/$fileName")
}
} else {
context.assets.open(src).use { inputStream ->
FileOutputStream(File(out)).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
}

View File

@@ -0,0 +1,364 @@
package com.sukisu.ultra.ui.kernelFlash.util
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.util.concurrent.TimeUnit
class RemoteToolsDownloader(
private val context: Context,
private val workDir: String
) {
companion object {
private const val TAG = "RemoteToolsDownloader"
// 远程下载URL配置
private const val KPTOOLS_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kptools"
private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg"
// 网络超时配置(毫秒)
private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时
private const val READ_TIMEOUT = 30000 // 30秒读取超时
// 最大重试次数
private const val MAX_RETRY_COUNT = 3
// 文件校验相关
private const val MIN_FILE_SIZE = 1024
}
interface DownloadProgressListener {
fun onProgress(fileName: String, progress: Int, total: Int)
fun onLog(message: String)
fun onError(fileName: String, error: String)
fun onSuccess(fileName: String, isRemote: Boolean)
}
data class DownloadResult(
val success: Boolean,
val isRemoteSource: Boolean,
val errorMessage: String? = null
)
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, DownloadResult>()
listener?.onLog("Starting to prepare KPM tool files...")
try {
// 确保工作目录存在
File(workDir).mkdirs()
// 并行下载两个工具文件
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
// 等待所有下载完成
results["kptools"] = kptoolsDeferred.await()
results["kpimg"] = kpimgDeferred.await()
// 检查kptools执行权限
val kptoolsFile = File(workDir, "kptools")
if (kptoolsFile.exists()) {
setExecutablePermission(kptoolsFile.absolutePath)
listener?.onLog("Set kptools execution permission")
}
val successCount = results.values.count { it.success }
val remoteCount = results.values.count { it.success && it.isRemoteSource }
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
} catch (e: Exception) {
Log.e(TAG, "Exception occurred while downloading tools", e)
listener?.onLog("Exception occurred during tool download: ${e.message}")
if (!results.containsKey("kptools")) {
results["kptools"] = downloadSingleTool("kptools", null, listener)
}
if (!results.containsKey("kpimg")) {
results["kpimg"] = downloadSingleTool("kpimg", null, listener)
}
}
results.toMap()
}
private suspend fun downloadSingleTool(
fileName: String,
remoteUrl: String?,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
val targetFile = File(workDir, fileName)
if (remoteUrl == null) {
return@withContext useLocalVersion(fileName, targetFile, listener)
}
// 尝试从远程下载
listener?.onLog("Downloading $fileName from remote repository...")
var lastError = ""
// 重试机制
repeat(MAX_RETRY_COUNT) { attempt ->
try {
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
if (result.success) {
listener?.onSuccess(fileName, true)
return@withContext result
}
lastError = result.errorMessage ?: "Unknown error"
} catch (e: Exception) {
lastError = e.message ?: "Network exception"
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
if (attempt < MAX_RETRY_COUNT - 1) {
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
}
}
}
// 所有重试都失败,回退到本地版本
listener?.onError(fileName, "Remote download failed: $lastError")
listener?.onLog("$fileName remote download failed, falling back to local version...")
useLocalVersion(fileName, targetFile, listener)
}
private suspend fun downloadFromRemote(
fileName: String,
remoteUrl: String,
targetFile: File,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(remoteUrl)
connection = url.openConnection() as HttpURLConnection
// 设置连接参数
connection.apply {
connectTimeout = CONNECTION_TIMEOUT
readTimeout = READ_TIMEOUT
requestMethod = "GET"
setRequestProperty("User-Agent", "SukiSU-KPM-Downloader/1.0")
setRequestProperty("Accept", "*/*")
setRequestProperty("Connection", "close")
}
// 建立连接
connection.connect()
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
return@withContext DownloadResult(
false,
isRemoteSource = false,
errorMessage = "HTTP error code: $responseCode"
)
}
val fileLength = connection.contentLength
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
// 创建临时文件
val tempFile = File(targetFile.absolutePath + ".tmp")
// 下载文件
connection.inputStream.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(8192)
var totalBytes = 0
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
// 检查协程是否被取消
ensureActive()
output.write(buffer, 0, bytesRead)
totalBytes += bytesRead
// 更新下载进度
if (fileLength > 0) {
listener?.onProgress(fileName, totalBytes, fileLength)
}
}
output.flush()
}
}
// 验证下载的文件
if (!validateDownloadedFile(tempFile, fileName)) {
tempFile.delete()
return@withContext DownloadResult(
success = false,
isRemoteSource = false,
errorMessage = "File verification failed"
)
}
// 移动临时文件到目标位置
if (targetFile.exists()) {
targetFile.delete()
}
if (!tempFile.renameTo(targetFile)) {
tempFile.delete()
return@withContext DownloadResult(
false,
isRemoteSource = false,
errorMessage = "Failed to move file"
)
}
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName remote download successful")
DownloadResult(true, isRemoteSource = true)
} catch (e: SocketTimeoutException) {
Log.w(TAG, "$fileName download timeout", e)
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
} catch (e: IOException) {
Log.w(TAG, "$fileName network IO exception", e)
DownloadResult(false,
isRemoteSource = false,
errorMessage = "Network connection exception: ${e.message}"
)
} catch (e: Exception) {
Log.e(TAG, "$fileName exception occurred during download", e)
DownloadResult(false,
isRemoteSource = false,
errorMessage = "Download exception: ${e.message}"
)
} finally {
connection?.disconnect()
}
}
private suspend fun useLocalVersion(
fileName: String,
targetFile: File,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
try {
AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
if (!targetFile.exists()) {
val errorMsg = "Local $fileName file extraction failed"
listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(false,
isRemoteSource = false,
errorMessage = errorMsg
)
}
if (!validateDownloadedFile(targetFile, fileName)) {
val errorMsg = "Local $fileName file verification failed"
listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(
success = false,
isRemoteSource = false,
errorMessage = errorMsg
)
}
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName local version loaded successfully")
listener?.onSuccess(fileName, false)
DownloadResult(true, isRemoteSource = false)
} catch (e: Exception) {
Log.e(TAG, "$fileName local version loading failed", e)
val errorMsg = "Local version loading failed: ${e.message}"
listener?.onError(fileName, errorMsg)
DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg)
}
}
private fun validateDownloadedFile(file: File, fileName: String): Boolean {
if (!file.exists()) {
Log.w(TAG, "$fileName file does not exist")
return false
}
val fileSize = file.length()
if (fileSize < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
return false
}
try {
file.inputStream().use { input ->
val header = ByteArray(4)
val bytesRead = input.read(header)
if (bytesRead < 4) {
Log.w(TAG, "$fileName file header read incomplete")
return false
}
val isELF = header[0] == 0x7F.toByte() &&
header[1] == 'E'.code.toByte() &&
header[2] == 'L'.code.toByte() &&
header[3] == 'F'.code.toByte()
if (fileName == "kptools" && !isELF) {
Log.w(TAG, "kptools file format is invalid, not ELF format")
return false
}
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF")
return true
}
} catch (e: Exception) {
Log.w(TAG, "$fileName file verification exception", e)
return false
}
}
private fun setExecutablePermission(filePath: String) {
try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath"))
process.waitFor()
Log.d(TAG, "Set execution permission for $filePath")
} catch (e: Exception) {
Log.w(TAG, "Failed to set execution permission: $filePath", e)
try {
File(filePath).setExecutable(true, false)
} catch (ex: Exception) {
Log.w(TAG, "Java method to set permissions also failed", ex)
}
}
}
fun cleanup() {
try {
File(workDir).listFiles()?.forEach { file ->
if (file.name.endsWith(".tmp")) {
file.delete()
Log.d(TAG, "Cleaned temporary file: ${file.name}")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clean temporary files", e)
}
}
}

View File

@@ -117,10 +117,7 @@ fun HomePager(
TopBar( TopBar(
kernelVersion = kernelVersion, kernelVersion = kernelVersion,
onInstallClick = { onInstallClick = {
navigator.navigate(InstallScreenDestination) { navigator.navigate(InstallScreenDestination()) {
popUpTo(InstallScreenDestination) {
inclusive = true
}
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -171,7 +168,7 @@ fun HomePager(
StatusCard( StatusCard(
kernelVersion, ksuVersion, lkmMode, kernelVersion, ksuVersion, lkmMode,
onClickInstall = { onClickInstall = {
navigator.navigate(InstallScreenDestination) { navigator.navigate(InstallScreenDestination()) {
launchSingleTop = true launchSingleTop = true
} }
}, },

View File

@@ -14,6 +14,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -46,11 +47,18 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.Security
import androidx.core.net.toUri
import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.compose.dropUnlessResumed
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.generated.destinations.FlashScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeStyle
@@ -58,9 +66,11 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.getKernelVersion
import com.sukisu.ultra.ui.component.ChooseKmiDialog import com.sukisu.ultra.ui.component.ChooseKmiDialog
import com.sukisu.ultra.ui.component.SuperDropdown import com.sukisu.ultra.ui.component.SuperDropdown
import com.sukisu.ultra.ui.component.rememberConfirmDialog import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.kernelFlash.component.SlotSelectionDialog
import com.sukisu.ultra.ui.util.LkmSelection import com.sukisu.ultra.ui.util.LkmSelection
import com.sukisu.ultra.ui.util.getAvailablePartitions import com.sukisu.ultra.ui.util.getAvailablePartitions
import com.sukisu.ultra.ui.util.getCurrentKmi import com.sukisu.ultra.ui.util.getCurrentKmi
@@ -84,6 +94,11 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Edit import top.yukonga.miuix.kmp.icon.icons.useful.Edit
import top.yukonga.miuix.kmp.icon.icons.useful.Move import top.yukonga.miuix.kmp.icon.icons.useful.Move
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.basic.TextButton
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.unit.DpSize
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize import top.yukonga.miuix.kmp.utils.getWindowSize
@@ -94,9 +109,18 @@ import top.yukonga.miuix.kmp.utils.scrollEndHaptic
* @author weishu * @author weishu
* @date 2024/3/12. * @date 2024/3/12.
*/ */
enum class KpmPatchOption {
FOLLOW_KERNEL,
PATCH_KPM,
UNDO_PATCH_KPM
}
@Composable @Composable
@Destination<RootGraph> @Destination<RootGraph>
fun InstallScreen(navigator: DestinationsNavigator) { fun InstallScreen(
navigator: DestinationsNavigator,
preselectedKernelUri: String? = null
) {
val context = LocalContext.current val context = LocalContext.current
var installMethod by remember { var installMethod by remember {
mutableStateOf<InstallMethod?>(null) mutableStateOf<InstallMethod?>(null)
@@ -106,26 +130,110 @@ fun InstallScreen(navigator: DestinationsNavigator) {
mutableStateOf<LkmSelection>(LkmSelection.KmiNone) mutableStateOf<LkmSelection>(LkmSelection.KmiNone)
} }
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
var showSlotSelectionDialog by remember { mutableStateOf(false) }
var showKpmPatchDialog by remember { mutableStateOf(false) }
var tempKernelUri by remember { mutableStateOf<Uri?>(null) }
val kernelVersion = getKernelVersion()
val isGKI = kernelVersion.isGKI()
val isAbDevice = produceState(initialValue = false) {
value = isAbDevice()
}.value
var partitionSelectionIndex by remember { mutableIntStateOf(0) } var partitionSelectionIndex by remember { mutableIntStateOf(0) }
var partitionsState by remember { mutableStateOf<List<String>>(emptyList()) } var partitionsState by remember { mutableStateOf<List<String>>(emptyList()) }
var hasCustomSelected by remember { mutableStateOf(false) } var hasCustomSelected by remember { mutableStateOf(false) }
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
// 处理预选的内核文件
LaunchedEffect(preselectedKernelUri) {
preselectedKernelUri?.let { uriString ->
try {
val preselectedUri = uriString.toUri()
val horizonMethod = InstallMethod.HorizonKernel(
uri = preselectedUri,
summary = horizonKernelSummary
)
installMethod = horizonMethod
tempKernelUri = preselectedUri
if (isAbDevice) {
showSlotSelectionDialog = true
} else {
showKpmPatchDialog = true
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
val onInstall = { val onInstall = {
installMethod?.let { method -> installMethod?.let { method ->
val isOta = method is InstallMethod.DirectInstallToInactiveSlot when (method) {
val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex) is InstallMethod.HorizonKernel -> {
val flashIt = FlashIt.FlashBoot( method.uri?.let { uri ->
boot = if (method is InstallMethod.SelectFile) method.uri else null, navigator.navigate(
lkm = lkmSelection, KernelFlashScreenDestination(
ota = isOta, kernelUri = uri,
partition = partitionSelection selectedSlot = method.slot,
) kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM,
navigator.navigate(FlashScreenDestination(flashIt)) { kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM
launchSingleTop = true )
) {
launchSingleTop = true
}
}
}
else -> {
val isOta = method is InstallMethod.DirectInstallToInactiveSlot
val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex)
val flashIt = FlashIt.FlashBoot(
boot = if (method is InstallMethod.SelectFile) method.uri else null,
lkm = lkmSelection,
ota = isOta,
partition = partitionSelection
)
navigator.navigate(FlashScreenDestination(flashIt)) {
launchSingleTop = true
}
}
} }
} }
} }
// 槽位选择对话框
if (showSlotSelectionDialog && isAbDevice) {
SlotSelectionDialog(
show = true,
onDismiss = { showSlotSelectionDialog = false },
onSlotSelected = { slot ->
showSlotSelectionDialog = false
val horizonMethod = InstallMethod.HorizonKernel(
uri = tempKernelUri,
slot = slot,
summary = horizonKernelSummary
)
installMethod = horizonMethod
// 槽位选择后,显示 KPM 补丁选择对话框
showKpmPatchDialog = true
}
)
}
// KPM补丁选择对话框
if (showKpmPatchDialog) {
KpmPatchSelectionDialog(
show = true,
currentOption = kpmPatchOption,
onDismiss = { showKpmPatchDialog = false },
onOptionSelected = { option ->
kpmPatchOption = option
showKpmPatchDialog = false
}
)
}
val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() } val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() }
val showChooseKmiDialog = rememberSaveable { mutableStateOf(false) } val showChooseKmiDialog = rememberSaveable { mutableStateOf(false) }
@@ -137,7 +245,7 @@ fun InstallScreen(navigator: DestinationsNavigator) {
} }
val onClickNext = { val onClickNext = {
if (lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) { if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank() && installMethod !is InstallMethod.HorizonKernel) {
// no lkm file selected and cannot get current kmi // no lkm file selected and cannot get current kmi
showChooseKmiDialog.value = true showChooseKmiDialog.value = true
chooseKmiDialog chooseKmiDialog
@@ -174,8 +282,8 @@ fun InstallScreen(navigator: DestinationsNavigator) {
val scrollBehavior = MiuixScrollBehavior() val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle( val hazeStyle = HazeStyle(
backgroundColor = MiuixTheme.colorScheme.background, backgroundColor = colorScheme.background,
tint = HazeTint(MiuixTheme.colorScheme.background.copy(0.8f)) tint = HazeTint(colorScheme.background.copy(0.8f))
) )
Scaffold( Scaffold(
@@ -207,9 +315,22 @@ fun InstallScreen(navigator: DestinationsNavigator) {
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
SelectInstallMethod { method -> SelectInstallMethod(
installMethod = method onSelected = { method ->
} if (method is InstallMethod.HorizonKernel && method.uri != null) {
if (isAbDevice) {
tempKernelUri = method.uri
showSlotSelectionDialog = true
} else {
installMethod = method
showKpmPatchDialog = true
}
} else {
installMethod = method
}
},
isAbDevice = isAbDevice
)
} }
AnimatedVisibility( AnimatedVisibility(
visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot, visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot,
@@ -256,29 +377,89 @@ fun InstallScreen(navigator: DestinationsNavigator) {
) )
} }
} }
Card( // LKM 上传选项(仅 GKI
modifier = Modifier if (isGKI) {
.fillMaxWidth() Card(
.padding(top = 12.dp), modifier = Modifier
) { .fillMaxWidth()
SuperArrow( .padding(top = 12.dp),
title = stringResource(id = R.string.install_upload_lkm_file), ) {
summary = (lkmSelection as? LkmSelection.LkmUri)?.let { SuperArrow(
stringResource( title = stringResource(id = R.string.install_upload_lkm_file),
id = R.string.selected_lkm, summary = (lkmSelection as? LkmSelection.LkmUri)?.let {
it.uri.lastPathSegment ?: "(file)" stringResource(
) id = R.string.selected_lkm,
}, it.uri.lastPathSegment ?: "(file)"
onClick = onLkmUpload, )
leftAction = { },
Icon( onClick = onLkmUpload,
MiuixIcons.Useful.Move, leftAction = {
tint = colorScheme.onSurface, Icon(
modifier = Modifier.padding(end = 16.dp), MiuixIcons.Useful.Move,
contentDescription = null tint = colorScheme.onSurface,
modifier = Modifier.padding(end = 16.dp),
contentDescription = null
)
}
)
}
}
// AnyKernel3 相关信息显示
(installMethod as? InstallMethod.HorizonKernel)?.let { method ->
if (method.slot != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
SuperArrow(
title = stringResource(
id = R.string.selected_slot,
if (method.slot == "a") stringResource(id = R.string.slot_a)
else stringResource(id = R.string.slot_b)
),
onClick = {},
leftAction = {
Icon(
Icons.Filled.SdStorage,
tint = colorScheme.onSurface,
modifier = Modifier.padding(end = 16.dp),
contentDescription = null
)
}
) )
} }
) }
// KPM 状态显示
if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
SuperArrow(
title = when (kpmPatchOption) {
KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled)
KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled)
else -> ""
},
onClick = {},
leftAction = {
Icon(
Icons.Filled.Security,
tint = if (kpmPatchOption == KpmPatchOption.PATCH_KPM)
colorScheme.primary
else
colorScheme.secondary,
modifier = Modifier.padding(end = 16.dp),
contentDescription = null
)
}
)
}
}
} }
Button( Button(
modifier = Modifier modifier = Modifier
@@ -322,19 +503,27 @@ sealed class InstallMethod {
get() = R.string.install_inactive_slot get() = R.string.install_inactive_slot
} }
data class HorizonKernel(
val uri: Uri? = null,
val slot: String? = null,
@get:StringRes override val label: Int = R.string.horizon_kernel,
override val summary: String? = null
) : InstallMethod()
abstract val label: Int abstract val label: Int
open val summary: String? = null open val summary: String? = null
} }
@Composable @Composable
private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { private fun SelectInstallMethod(
onSelected: (InstallMethod) -> Unit = {},
isAbDevice: Boolean = false
) {
val rootAvailable = rootAvailable() val rootAvailable = rootAvailable()
val isAbDevice = produceState(initialValue = false) {
value = isAbDevice()
}.value
val defaultPartitionName = produceState(initialValue = "boot") { val defaultPartitionName = produceState(initialValue = "boot") {
value = getDefaultPartition() value = getDefaultPartition()
}.value }.value
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
val selectFileTip = stringResource( val selectFileTip = stringResource(
id = R.string.select_file_tip, defaultPartitionName id = R.string.select_file_tip, defaultPartitionName
) )
@@ -345,17 +534,26 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
if (isAbDevice) { if (isAbDevice) {
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot) radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
} }
radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary))
} }
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) } var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
var currentSelectingMethod by remember { mutableStateOf<InstallMethod?>(null) }
val selectImageLauncher = rememberLauncherForActivityResult( val selectImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
) { ) {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> it.data?.data?.let { uri ->
val option = InstallMethod.SelectFile(uri, summary = selectFileTip) val option = when (currentSelectingMethod) {
selectedOption = option is InstallMethod.SelectFile -> InstallMethod.SelectFile(uri, summary = selectFileTip)
onSelected(option) is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(uri, summary = horizonKernelSummary)
else -> null
}
option?.let { opt ->
selectedOption = opt
onSelected(opt)
}
} }
} }
} }
@@ -370,11 +568,12 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning) val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
val onClick = { option: InstallMethod -> val onClick = { option: InstallMethod ->
currentSelectingMethod = option
when (option) { when (option) {
is InstallMethod.SelectFile -> { is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> {
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "application/octet-stream" type = "application/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream", "application/zip"))
}) })
} }
@@ -450,6 +649,121 @@ private fun TopBar(
) )
} }
@Composable
private fun KpmPatchSelectionDialog(
show: Boolean,
currentOption: KpmPatchOption,
onDismiss: () -> Unit,
onOptionSelected: (KpmPatchOption) -> Unit
) {
var selectedOption by remember { mutableStateOf(currentOption) }
val showDialog = remember { mutableStateOf(show) }
LaunchedEffect(show) {
showDialog.value = show
if (show) {
selectedOption = currentOption
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
onDismiss()
},
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
// 标题
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
text = stringResource(id = R.string.kpm_patch_options),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
// 描述
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(id = R.string.kpm_patch_description),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
// 选项列表
val options = listOf(
KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file),
KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch),
KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch)
)
options.forEach { (option, title) ->
SuperArrow(
title = title,
onClick = {
selectedOption = option
},
leftAction = {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = null,
tint = if (selectedOption == option) {
colorScheme.primary
} else {
colorScheme.onSurfaceVariantSummary
}
)
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
// 按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
onOptionSelected(selectedOption)
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
}
}
}
)
}
private fun isKoFile(context: Context, uri: Uri): Boolean { private fun isKoFile(context: Context, uri: Uri): Boolean {
val seg = uri.lastPathSegment ?: "" val seg = uri.lastPathSegment ?: ""
if (seg.endsWith(".ko", ignoreCase = true)) return true if (seg.endsWith(".ko", ignoreCase = true)) return true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
package com.sukisu.ultra.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sukisu.ultra.ui.component.SearchStatus
import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
class KpmViewModel : ViewModel() {
private var _moduleList by mutableStateOf(emptyList<ModuleInfo>())
val moduleList by derivedStateOf {
val searchText = _searchStatus.value.searchText
if (searchText.isEmpty()) {
_moduleList
} else {
_moduleList.filter {
it.id.contains(searchText, true) ||
it.name.contains(searchText, true) ||
it.description.contains(searchText, true) ||
it.author.contains(searchText, true) ||
it.version.contains(searchText, true)
}
}
}
private val _searchStatus = mutableStateOf(SearchStatus(""))
val searchStatus: State<SearchStatus> = _searchStatus
var isRefreshing by mutableStateOf(false)
private set
var currentModuleDetail by mutableStateOf("")
private set
fun fetchModuleList() {
viewModelScope.launch {
isRefreshing = true
try {
val moduleCount = getKpmModuleCount()
Log.d("KsuCli", "Module count: $moduleCount")
_moduleList = getAllKpmModuleInfo()
// 获取 KPM 版本信息
val kpmVersion = getKpmVersion()
Log.d("KsuCli", "KPM Version: $kpmVersion")
} catch (e: Exception) {
Log.e("KsuCli", "获取模块列表失败", e)
} finally {
isRefreshing = false
}
}
}
private fun getAllKpmModuleInfo(): List<ModuleInfo> {
val result = mutableListOf<ModuleInfo>()
try {
val str = listKpmModules()
val moduleNames = str
.split("\n")
.filter { it.isNotBlank() }
for (name in moduleNames) {
try {
val moduleInfo = parseModuleInfo(name)
moduleInfo?.let { result.add(it) }
} catch (e: Exception) {
Log.e("KsuCli", "Error processing module $name", e)
}
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to get module list", e)
}
return result
}
private fun parseModuleInfo(name: String): ModuleInfo? {
val info = getKpmModuleInfo(name)
if (info.isBlank()) return null
val properties = info.lineSequence()
.filter { line ->
val trimmed = line.trim()
trimmed.isNotEmpty() && !trimmed.startsWith("#")
}
.mapNotNull { line ->
line.split("=", limit = 2).let { parts ->
when (parts.size) {
2 -> parts[0].trim() to parts[1].trim()
1 -> parts[0].trim() to ""
else -> null
}
}
}
.toMap()
return ModuleInfo(
id = name,
name = properties["name"] ?: name,
version = properties["version"] ?: "",
author = properties["author"] ?: "",
description = properties["description"] ?: "",
args = properties["args"] ?: "",
enabled = true,
hasAction = true
)
}
fun loadModuleDetail(moduleId: String) {
viewModelScope.launch {
try {
currentModuleDetail = withContext(Dispatchers.IO) {
getKpmModuleInfo(moduleId)
}
Log.d("KsuCli", "Module detail loaded: $currentModuleDetail")
} catch (e: Exception) {
Log.e("KsuCli", "Failed to load module detail", e)
currentModuleDetail = "Error: ${e.message}"
}
}
}
var showInputDialog by mutableStateOf(false)
private set
var selectedModuleId by mutableStateOf<String?>(null)
private set
var inputArgs by mutableStateOf("")
private set
fun showInputDialog(moduleId: String) {
selectedModuleId = moduleId
showInputDialog = true
}
fun hideInputDialog() {
showInputDialog = false
selectedModuleId = null
inputArgs = ""
}
fun updateInputArgs(args: String) {
inputArgs = args
}
fun executeControl(): Int {
val moduleId = selectedModuleId ?: return -1
val result = controlKpmModule(moduleId, inputArgs)
hideInputDialog()
return result
}
fun updateSearchText(text: String) {
_searchStatus.value.searchText = text
}
data class ModuleInfo(
val id: String,
val name: String,
val version: String,
val author: String,
val description: String,
val args: String,
val enabled: Boolean,
val hasAction: Boolean
)
}

View File

@@ -246,4 +246,93 @@
<string name="theme_light">浅色</string> <string name="theme_light">浅色</string>
<string name="theme_dark">深色</string> <string name="theme_dark">深色</string>
<string name="theme_follow_system">跟随系统</string> <string name="theme_follow_system">跟随系统</string>
<!-- Kernel Flash Progress Related -->
<string name="horizon_flash_complete">刷入完成</string>
<string name="horizon_preparing">准备中…</string>
<string name="horizon_cleaning_files">清理文件…</string>
<string name="horizon_copying_files">复制文件…</string>
<string name="horizon_extracting_tool">解压刷机工具…</string>
<string name="horizon_patching_script">修补刷机脚本…</string>
<string name="horizon_flashing">正在刷入内核…</string>
<string name="horizon_flash_complete_status">刷入已完成</string>
<string name="select_slot_title">选择刷入槽位</string>
<string name="select_slot_description">请选择要刷入boot的目标槽位</string>
<string name="slot_a">槽位 A</string>
<string name="slot_b">槽位 B</string>
<string name="selected_slot">已选槽位: %1$s</string>
<string name="horizon_getting_original_slot">获取原始槽位</string>
<string name="horizon_setting_target_slot">设置指定槽位</string>
<string name="horizon_restoring_original_slot">恢复默认槽位</string>
<string name="current_slot">当前系统默认槽位:%1$s </string>
<string name="horizon_copy_failed">复制失败</string>
<string name="horizon_unknown_error">未知错误</string>
<string name="flash_failed_message">刷入失败</string>
<string name="Lkm_install_methods">LKM 修复/安装</string>
<string name="GKI_install_methods">刷入 AnyKernel3</string>
<string name="kernel_version_log">内核版本:%1$s</string>
<string name="tool_version_log">使用的修补工具:%1$s</string>
<string name="configuration">配置</string>
<string name="app_settings">应用程序设置</string>
<string name="tools">工具</string>
<string name="root_required">需要root权限</string>
<string name="kpm_patch_options">KPM 补丁</string>
<string name="kpm_patch_description">用于添加额外的KPM功能</string>
<string name="enable_kpm_patch">KPM 补丁</string>
<string name="kpm_patch_switch_description">在刷入前对内核镜像应用KPM补丁</string>
<string name="enable_kpm_undo_patch">KPM 撤销补丁</string>
<string name="kpm_undo_patch_switch_description">撤销之前应用的KPM补丁</string>
<string name="kpm_patch_enabled">KPM 补丁已启用</string>
<string name="kpm_undo_patch_enabled">KPM 撤销补丁已启用</string>
<string name="kpm_patch_mode">KPM 补丁模式</string>
<string name="kpm_undo_patch_mode">KPM 撤销补丁模式</string>
<string name="kpm_preparing_tools">准备KPM工具</string>
<string name="kpm_applying_patch">应用KPM补丁</string>
<string name="kpm_undoing_patch">撤销KPM补丁</string>
<string name="kpm_found_image_file">找到镜像文件: %s</string>
<string name="kpm_patch_success">KPM 补丁应用成功</string>
<string name="kpm_undo_patch_success">KPM 补丁撤销成功</string>
<string name="kpm_file_repacked">文件重新打包成功</string>
<string name="kpm_extract_zip_failed">解压zip文件失败</string>
<string name="kpm_image_file_not_found">未找到镜像文件</string>
<string name="kpm_patch_failed">KPM 补丁失败</string>
<string name="kpm_undo_patch_failed">KPM 撤销补丁失败</string>
<string name="kpm_patch_operation_failed">KPM 补丁操作失败: %s</string>
<string name="kpm_follow_kernel_file">跟随内核</string>
<string name="kpm_follow_kernel_description">原样使用内核不进行任何KPM修改</string>
<string name="kernel_flashing">内核刷入</string>
<string name="horizon_kernel">AnyKernel3 内核</string>
<string name="horizon_kernel_summary">刷入AnyKernel3格式的内核zip包</string>
<!-- kpm -->
<string name="kpm_title">KPM</string>
<string name="kpm_empty">当前没有安装内核模块</string>
<string name="kpm_version">版本</string>
<string name="kpm_author">作者</string>
<string name="kpm_uninstall">卸载</string>
<string name="kpm_uninstall_success">卸载成功</string>
<string name="kpm_uninstall_failed">卸载失败</string>
<string name="kpm_install_success">kpm 模块加载成功</string>
<string name="kpm_install_failed">kpm 模块加载失败</string>
<string name="kpm_args">参数</string>
<string name="kpm_control">执行</string>
<string name="home_kpm_version">KPM 版本</string>
<string name="close_notice">关闭</string>
<string name="kernel_module_notice">以下内核模块功能由 KernelPatch 开发,并经过修改后加入了 SukiSU Ultra 的内核模块功能</string>
<string name="home_ContributionCard_kernelsu">SukiSU Ultra 期待</string>
<string name="kpm_control_success">成功</string>
<string name="kpm_control_failed">失败</string>
<string name="home_click_to_ContributionCard_kernelsu">SukiSU Ultra 在未来将是 KSU 的相对独立分支,但我们仍然感谢官方 KernelSU 和 MKSU 等的贡献!</string>
<string name="not_supported">不支持</string>
<string name="supported">支持</string>
<string name="kernel_patched">内核未修补</string>
<string name="kernel_not_enabled">内核未配置</string>
<string name="custom_settings">自定义设置</string>
<string name="kpm_install_mode">KPM 安装</string>
<string name="kpm_install_mode_load">加载</string>
<string name="kpm_install_mode_embed">嵌入</string>
<string name="kpm_install_mode_description">请选择:%1$s 模块安装模式\n\n加载临时加载模块\n嵌入永久安装到系统中</string>
<string name="invalid_file_type">文件类型不正确!请选择 .kpm 文件</string>
<string name="confirm_uninstall_title_with_filename">卸载</string>
<string name="confirm_uninstall_content">以下 KPM 将被卸载:%s</string>
<string name="snackbar_failed_to_check_module_file">无法检查模块文件是否存在</string>
<string name="cancel">取消</string>
</resources> </resources>

View File

@@ -250,4 +250,93 @@
<string name="theme_light">Light</string> <string name="theme_light">Light</string>
<string name="theme_dark">Dark</string> <string name="theme_dark">Dark</string>
<string name="theme_follow_system">Follow System</string> <string name="theme_follow_system">Follow System</string>
<!-- Kernel Flash Progress Related -->
<string name="horizon_flash_complete">Flash Complete</string>
<string name="horizon_preparing">Preparing…</string>
<string name="horizon_cleaning_files">Cleaning files…</string>
<string name="horizon_copying_files">Copying files…</string>
<string name="horizon_extracting_tool">Extracting flash tool…</string>
<string name="horizon_patching_script">Patching flash script…</string>
<string name="horizon_flashing">Flashing kernel…</string>
<string name="horizon_flash_complete_status">Flash completed</string>
<string name="select_slot_title">Select Flash Slot</string>
<string name="select_slot_description">Please select the target slot for flashing boot</string>
<string name="slot_a">Slot A</string>
<string name="slot_b">Slot B</string>
<string name="selected_slot">Selected slot: %1$s</string>
<string name="horizon_getting_original_slot">Getting the original slot</string>
<string name="horizon_setting_target_slot">Setting the specified slot</string>
<string name="horizon_restoring_original_slot">Restore Default Slot</string>
<string name="current_slot">Current system default slot%1$s </string>
<string name="horizon_copy_failed">Copy failed</string>
<string name="horizon_unknown_error">Unknown error</string>
<string name="flash_failed_message">Flash failed</string>
<string name="Lkm_install_methods">LKM repair/installation</string>
<string name="GKI_install_methods">Flashing AnyKernel3</string>
<string name="kernel_version_log">Kernel version%1$s</string>
<string name="tool_version_log">Using the patching tool%1$s</string>
<string name="configuration">Configure</string>
<string name="app_settings">Application Settings</string>
<string name="tools">Tools</string>
<string name="root_required">Requires root privileges</string>
<string name="kpm_patch_options">KPM Patch</string>
<string name="kpm_patch_description">For adding additional KPM features</string>
<string name="enable_kpm_patch">KPM Patch</string>
<string name="kpm_patch_switch_description">Apply KPM patch to kernel image before flashing</string>
<string name="enable_kpm_undo_patch">KPM Undo Patch</string>
<string name="kpm_undo_patch_switch_description">Undo previously applied KPM patch</string>
<string name="kpm_patch_enabled">KPM patch enabled</string>
<string name="kpm_undo_patch_enabled">KPM undo patch enabled</string>
<string name="kpm_patch_mode">KPM Patch Mode</string>
<string name="kpm_undo_patch_mode">KPM Undo Patch Mode</string>
<string name="kpm_preparing_tools">Preparing KPM tools</string>
<string name="kpm_applying_patch">Applying KPM patch</string>
<string name="kpm_undoing_patch">Undoing KPM patch</string>
<string name="kpm_found_image_file">Found Image file: %s</string>
<string name="kpm_patch_success">KPM patch applied successfully</string>
<string name="kpm_undo_patch_success">KPM patch undone successfully</string>
<string name="kpm_file_repacked">File repacked successfully</string>
<string name="kpm_extract_zip_failed">Failed to extract zip file</string>
<string name="kpm_image_file_not_found">Image file not found</string>
<string name="kpm_patch_failed">KPM patch failed</string>
<string name="kpm_undo_patch_failed">KPM undo patch failed</string>
<string name="kpm_patch_operation_failed">KPM patch operation failed: %s</string>
<string name="kpm_follow_kernel_file">Follow Kernel</string>
<string name="kpm_follow_kernel_description">Use kernel as-is without any KPM modifications</string>
<string name="kernel_flashing">Kernel Flashing</string>
<string name="horizon_kernel">AnyKernel3 Kernel</string>
<string name="horizon_kernel_summary">Flash AnyKernel3 format kernel zip</string>
<!-- kpm -->
<string name="kpm_title">KPM</string>
<string name="kpm_empty">No installed kernel modules at this time</string>
<string name="kpm_version">Version</string>
<string name="kpm_author">Author</string>
<string name="kpm_uninstall">Uninstall</string>
<string name="kpm_uninstall_success">Uninstalled successfully</string>
<string name="kpm_uninstall_failed">Failed to uninstall</string>
<string name="kpm_install_success">Load of kpm module successful</string>
<string name="kpm_install_failed">Load of kpm module failed</string>
<string name="kpm_args">Parameters</string>
<string name="kpm_control">Execute</string>
<string name="home_kpm_version">KPM Version</string>
<string name="close_notice">Close</string>
<string name="kernel_module_notice">The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra</string>
<string name="home_ContributionCard_kernelsu">SukiSU Ultra Look forward to</string>
<string name="kpm_control_success">Success</string>
<string name="kpm_control_failed">Failed</string>
<string name="home_click_to_ContributionCard_kernelsu">SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions!</string>
<string name="not_supported">Unsupported</string>
<string name="supported">Supported</string>
<string name="kernel_patched">Kernel not patched</string>
<string name="kernel_not_enabled">Kernel not configured</string>
<string name="custom_settings">Custom settings</string>
<string name="kpm_install_mode">KPM Install</string>
<string name="kpm_install_mode_load">Load</string>
<string name="kpm_install_mode_embed">Embed</string>
<string name="kpm_install_mode_description">Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system</string>
<string name="invalid_file_type">Incorrect file type! Please select .kpm file</string>
<string name="confirm_uninstall_title_with_filename">Uninstall</string>
<string name="confirm_uninstall_content">The following KPM will be uninstalled: %s</string>
<string name="snackbar_failed_to_check_module_file">Unable to check if module file exists</string>
<string name="cancel">Cancel</string>
</resources> </resources>

View File

@@ -20,6 +20,7 @@ cmaker = "1.2"
miuix = "0.6.1" miuix = "0.6.1"
haze = "1.7.0" haze = "1.7.0"
capsule = "2.1.1" capsule = "2.1.1"
documentfile = "1.1.0"
[plugins] [plugins]
agp-app = { id = "com.android.application", version.ref = "agp" } agp-app = { id = "com.android.application", version.ref = "agp" }
@@ -73,4 +74,5 @@ miuix = { module = "top.yukonga.miuix.kmp:miuix-android", version.ref = "miuix"
haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" }
capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }