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.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.documentfile)
debugImplementation(libs.androidx.compose.ui.test.manifest)
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.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
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.navigation.NavBackStackEntry
import androidx.navigation.compose.rememberNavController
@@ -39,6 +44,7 @@ import kotlinx.coroutines.launch
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.component.BottomBar
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.SettingPager
import com.sukisu.ultra.ui.screen.SuperUserPager
@@ -120,7 +126,22 @@ val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle
fun MainScreen(navController: DestinationsNavigator) {
val activity = LocalActivity.current
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 hazeStyle = HazeStyle(
backgroundColor = MiuixTheme.colorScheme.background,
@@ -148,7 +169,7 @@ fun MainScreen(navController: DestinationsNavigator) {
) {
Scaffold(
bottomBar = {
BottomBar(hazeState, hazeStyle)
BottomBar(hazeState, hazeStyle, isKpmAvailable)
},
) { innerPadding ->
HorizontalPager(
@@ -157,11 +178,24 @@ fun MainScreen(navController: DestinationsNavigator) {
beyondViewportPageCount = 2,
userScrollEnabled = false
) {
when (it) {
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
when {
isKpmAvailable -> {
when (it) {
0 -> HomePager(pagerState, 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.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Cottage
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Security
@@ -27,7 +28,8 @@ import top.yukonga.miuix.kmp.basic.NavigationItem
@Composable
fun BottomBar(
hazeState: HazeState,
hazeStyle: HazeStyle
hazeStyle: HazeStyle,
isKpmAvailable: Boolean = false
) {
val isManager = Natives.isManager
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
@@ -37,12 +39,24 @@ fun BottomBar(
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(
label = stringResource(destination.label),
icon = destination.icon,
)
}
val bottomBarIndex = if (!isKpmAvailable) {
page.coerceIn(0, item.size - 1)
} else {
page.coerceIn(0, item.size - 1)
}
NavigationBar(
modifier = Modifier
@@ -53,8 +67,10 @@ fun BottomBar(
},
color = Color.Transparent,
items = item,
selected = page,
onClick = handlePageChange
selected = bottomBarIndex,
onClick = { index ->
handlePageChange(index)
}
)
}
@@ -63,6 +79,7 @@ enum class BottomBarDestination(
val icon: ImageVector,
) {
Home(R.string.home, Icons.Rounded.Cottage),
KPM(R.string.kpm_title, Icons.Rounded.Code),
SuperUser(R.string.superuser, Icons.Rounded.Security),
Module(R.string.module, Icons.Rounded.Extension),
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(
kernelVersion = kernelVersion,
onInstallClick = {
navigator.navigate(InstallScreenDestination) {
popUpTo(InstallScreenDestination) {
inclusive = true
}
navigator.navigate(InstallScreenDestination()) {
launchSingleTop = true
}
},
@@ -171,7 +168,7 @@ fun HomePager(
StatusCard(
kernelVersion, ksuVersion, lkmMode,
onClickInstall = {
navigator.navigate(InstallScreenDestination) {
navigator.navigate(InstallScreenDestination()) {
launchSingleTop = true
}
},

View File

@@ -14,6 +14,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.res.stringResource
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.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 com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
@@ -58,9 +66,11 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.R
import com.sukisu.ultra.getKernelVersion
import com.sukisu.ultra.ui.component.ChooseKmiDialog
import com.sukisu.ultra.ui.component.SuperDropdown
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.getAvailablePartitions
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.Edit
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.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize
@@ -94,9 +109,18 @@ import top.yukonga.miuix.kmp.utils.scrollEndHaptic
* @author weishu
* @date 2024/3/12.
*/
enum class KpmPatchOption {
FOLLOW_KERNEL,
PATCH_KPM,
UNDO_PATCH_KPM
}
@Composable
@Destination<RootGraph>
fun InstallScreen(navigator: DestinationsNavigator) {
fun InstallScreen(
navigator: DestinationsNavigator,
preselectedKernelUri: String? = null
) {
val context = LocalContext.current
var installMethod by remember {
mutableStateOf<InstallMethod?>(null)
@@ -106,26 +130,110 @@ fun InstallScreen(navigator: DestinationsNavigator) {
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 partitionsState by remember { mutableStateOf<List<String>>(emptyList()) }
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 = {
installMethod?.let { method ->
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
when (method) {
is InstallMethod.HorizonKernel -> {
method.uri?.let { uri ->
navigator.navigate(
KernelFlashScreenDestination(
kernelUri = uri,
selectedSlot = method.slot,
kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM,
kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM
)
) {
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 showChooseKmiDialog = rememberSaveable { mutableStateOf(false) }
@@ -137,7 +245,7 @@ fun InstallScreen(navigator: DestinationsNavigator) {
}
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
showChooseKmiDialog.value = true
chooseKmiDialog
@@ -174,8 +282,8 @@ fun InstallScreen(navigator: DestinationsNavigator) {
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = MiuixTheme.colorScheme.background,
tint = HazeTint(MiuixTheme.colorScheme.background.copy(0.8f))
backgroundColor = colorScheme.background,
tint = HazeTint(colorScheme.background.copy(0.8f))
)
Scaffold(
@@ -207,9 +315,22 @@ fun InstallScreen(navigator: DestinationsNavigator) {
modifier = Modifier
.fillMaxWidth(),
) {
SelectInstallMethod { method ->
installMethod = method
}
SelectInstallMethod(
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(
visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot,
@@ -256,29 +377,89 @@ fun InstallScreen(navigator: DestinationsNavigator) {
)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
SuperArrow(
title = stringResource(id = R.string.install_upload_lkm_file),
summary = (lkmSelection as? LkmSelection.LkmUri)?.let {
stringResource(
id = R.string.selected_lkm,
it.uri.lastPathSegment ?: "(file)"
)
},
onClick = onLkmUpload,
leftAction = {
Icon(
MiuixIcons.Useful.Move,
tint = colorScheme.onSurface,
modifier = Modifier.padding(end = 16.dp),
contentDescription = null
// LKM 上传选项(仅 GKI
if (isGKI) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
SuperArrow(
title = stringResource(id = R.string.install_upload_lkm_file),
summary = (lkmSelection as? LkmSelection.LkmUri)?.let {
stringResource(
id = R.string.selected_lkm,
it.uri.lastPathSegment ?: "(file)"
)
},
onClick = onLkmUpload,
leftAction = {
Icon(
MiuixIcons.Useful.Move,
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(
modifier = Modifier
@@ -322,19 +503,27 @@ sealed class InstallMethod {
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
open val summary: String? = null
}
@Composable
private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
private fun SelectInstallMethod(
onSelected: (InstallMethod) -> Unit = {},
isAbDevice: Boolean = false
) {
val rootAvailable = rootAvailable()
val isAbDevice = produceState(initialValue = false) {
value = isAbDevice()
}.value
val defaultPartitionName = produceState(initialValue = "boot") {
value = getDefaultPartition()
}.value
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
val selectFileTip = stringResource(
id = R.string.select_file_tip, defaultPartitionName
)
@@ -345,17 +534,26 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
if (isAbDevice) {
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
}
radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary))
}
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
var currentSelectingMethod by remember { mutableStateOf<InstallMethod?>(null) }
val selectImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri ->
val option = InstallMethod.SelectFile(uri, summary = selectFileTip)
selectedOption = option
onSelected(option)
val option = when (currentSelectingMethod) {
is InstallMethod.SelectFile -> InstallMethod.SelectFile(uri, summary = selectFileTip)
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 onClick = { option: InstallMethod ->
currentSelectingMethod = option
when (option) {
is InstallMethod.SelectFile -> {
is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> {
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 {
val seg = uri.lastPathSegment ?: ""
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_dark">深色</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>

View File

@@ -250,4 +250,93 @@
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</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>

View File

@@ -20,6 +20,7 @@ cmaker = "1.2"
miuix = "0.6.1"
haze = "1.7.0"
capsule = "2.1.1"
documentfile = "1.1.0"
[plugins]
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" }
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" }