Step 4: Add KPM interface and flash anykernel3
This commit is contained in:
@@ -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)
|
||||
|
||||
BIN
manager/app/src/main/assets/5_10-mkbootfs
Normal file
BIN
manager/app/src/main/assets/5_10-mkbootfs
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/5_15+-mkbootfs
Normal file
BIN
manager/app/src/main/assets/5_15+-mkbootfs
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/kpimg
Normal file
BIN
manager/app/src/main/assets/kpimg
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/kptools
Normal file
BIN
manager/app/src/main/assets/kptools
Normal file
Binary file not shown.
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
1032
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt
Normal file
1032
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
Reference in New Issue
Block a user