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.material.icons.extended)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|||||||
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.sukisu.ultra.ui.util.getKpmVersion
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -39,6 +44,7 @@ import kotlinx.coroutines.launch
|
|||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.ui.component.BottomBar
|
import com.sukisu.ultra.ui.component.BottomBar
|
||||||
import com.sukisu.ultra.ui.screen.HomePager
|
import com.sukisu.ultra.ui.screen.HomePager
|
||||||
|
import com.sukisu.ultra.ui.screen.KpmScreen
|
||||||
import com.sukisu.ultra.ui.screen.ModulePager
|
import com.sukisu.ultra.ui.screen.ModulePager
|
||||||
import com.sukisu.ultra.ui.screen.SettingPager
|
import com.sukisu.ultra.ui.screen.SettingPager
|
||||||
import com.sukisu.ultra.ui.screen.SuperUserPager
|
import com.sukisu.ultra.ui.screen.SuperUserPager
|
||||||
@@ -120,7 +126,22 @@ val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle
|
|||||||
fun MainScreen(navController: DestinationsNavigator) {
|
fun MainScreen(navController: DestinationsNavigator) {
|
||||||
val activity = LocalActivity.current
|
val activity = LocalActivity.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 })
|
|
||||||
|
// 检查 KPM 版本是否可用
|
||||||
|
val kpmVersion by produceState(initialValue = "") {
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
getKpmVersion()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isKpmAvailable = kpmVersion.isNotEmpty() && !kpmVersion.contains("Error", ignoreCase = true)
|
||||||
|
val pageCount = if (isKpmAvailable) 5 else 4
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState(initialPage = 0, pageCount = { pageCount })
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val hazeStyle = HazeStyle(
|
val hazeStyle = HazeStyle(
|
||||||
backgroundColor = MiuixTheme.colorScheme.background,
|
backgroundColor = MiuixTheme.colorScheme.background,
|
||||||
@@ -148,7 +169,7 @@ fun MainScreen(navController: DestinationsNavigator) {
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
BottomBar(hazeState, hazeStyle)
|
BottomBar(hazeState, hazeStyle, isKpmAvailable)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
@@ -157,11 +178,24 @@ fun MainScreen(navController: DestinationsNavigator) {
|
|||||||
beyondViewportPageCount = 2,
|
beyondViewportPageCount = 2,
|
||||||
userScrollEnabled = false
|
userScrollEnabled = false
|
||||||
) {
|
) {
|
||||||
when (it) {
|
when {
|
||||||
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
|
isKpmAvailable -> {
|
||||||
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
|
when (it) {
|
||||||
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
|
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
|
||||||
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
|
1 -> KpmScreen(bottomInnerPadding = innerPadding.calculateBottomPadding())
|
||||||
|
2 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
3 -> ModulePager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
4 -> SettingPager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
when (it) {
|
||||||
|
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
|
||||||
|
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sukisu.ultra.ui.component
|
|||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Code
|
||||||
import androidx.compose.material.icons.rounded.Cottage
|
import androidx.compose.material.icons.rounded.Cottage
|
||||||
import androidx.compose.material.icons.rounded.Extension
|
import androidx.compose.material.icons.rounded.Extension
|
||||||
import androidx.compose.material.icons.rounded.Security
|
import androidx.compose.material.icons.rounded.Security
|
||||||
@@ -27,7 +28,8 @@ import top.yukonga.miuix.kmp.basic.NavigationItem
|
|||||||
@Composable
|
@Composable
|
||||||
fun BottomBar(
|
fun BottomBar(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
hazeStyle: HazeStyle
|
hazeStyle: HazeStyle,
|
||||||
|
isKpmAvailable: Boolean = false
|
||||||
) {
|
) {
|
||||||
val isManager = Natives.isManager
|
val isManager = Natives.isManager
|
||||||
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
|
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
|
||||||
@@ -37,13 +39,25 @@ fun BottomBar(
|
|||||||
|
|
||||||
if (!fullFeatured) return
|
if (!fullFeatured) return
|
||||||
|
|
||||||
val item = BottomBarDestination.entries.mapIndexed { index, destination ->
|
val destinations = if (isKpmAvailable) {
|
||||||
|
BottomBarDestination.entries
|
||||||
|
} else {
|
||||||
|
BottomBarDestination.entries.filter { it != BottomBarDestination.KPM }
|
||||||
|
}
|
||||||
|
|
||||||
|
val item = destinations.mapIndexed { index, destination ->
|
||||||
NavigationItem(
|
NavigationItem(
|
||||||
label = stringResource(destination.label),
|
label = stringResource(destination.label),
|
||||||
icon = destination.icon,
|
icon = destination.icon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bottomBarIndex = if (!isKpmAvailable) {
|
||||||
|
page.coerceIn(0, item.size - 1)
|
||||||
|
} else {
|
||||||
|
page.coerceIn(0, item.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeEffect(hazeState) {
|
.hazeEffect(hazeState) {
|
||||||
@@ -53,8 +67,10 @@ fun BottomBar(
|
|||||||
},
|
},
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
items = item,
|
items = item,
|
||||||
selected = page,
|
selected = bottomBarIndex,
|
||||||
onClick = handlePageChange
|
onClick = { index ->
|
||||||
|
handlePageChange(index)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +79,7 @@ enum class BottomBarDestination(
|
|||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
) {
|
) {
|
||||||
Home(R.string.home, Icons.Rounded.Cottage),
|
Home(R.string.home, Icons.Rounded.Cottage),
|
||||||
|
KPM(R.string.kpm_title, Icons.Rounded.Code),
|
||||||
SuperUser(R.string.superuser, Icons.Rounded.Security),
|
SuperUser(R.string.superuser, Icons.Rounded.Security),
|
||||||
Module(R.string.module, Icons.Rounded.Extension),
|
Module(R.string.module, Icons.Rounded.Extension),
|
||||||
Setting(R.string.settings, Icons.Rounded.Settings)
|
Setting(R.string.settings, Icons.Rounded.Settings)
|
||||||
|
|||||||
@@ -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(
|
TopBar(
|
||||||
kernelVersion = kernelVersion,
|
kernelVersion = kernelVersion,
|
||||||
onInstallClick = {
|
onInstallClick = {
|
||||||
navigator.navigate(InstallScreenDestination) {
|
navigator.navigate(InstallScreenDestination()) {
|
||||||
popUpTo(InstallScreenDestination) {
|
|
||||||
inclusive = true
|
|
||||||
}
|
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,7 +168,7 @@ fun HomePager(
|
|||||||
StatusCard(
|
StatusCard(
|
||||||
kernelVersion, ksuVersion, lkmMode,
|
kernelVersion, ksuVersion, lkmMode,
|
||||||
onClickInstall = {
|
onClickInstall = {
|
||||||
navigator.navigate(InstallScreenDestination) {
|
navigator.navigate(InstallScreenDestination()) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.animation.expandVertically
|
|||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.LocalIndication
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -46,11 +47,18 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SdStorage
|
||||||
|
import androidx.compose.material.icons.filled.Security
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
import androidx.lifecycle.compose.dropUnlessResumed
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||||
|
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
@@ -58,9 +66,11 @@ import dev.chrisbanes.haze.HazeTint
|
|||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.getKernelVersion
|
||||||
import com.sukisu.ultra.ui.component.ChooseKmiDialog
|
import com.sukisu.ultra.ui.component.ChooseKmiDialog
|
||||||
import com.sukisu.ultra.ui.component.SuperDropdown
|
import com.sukisu.ultra.ui.component.SuperDropdown
|
||||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||||
|
import com.sukisu.ultra.ui.kernelFlash.component.SlotSelectionDialog
|
||||||
import com.sukisu.ultra.ui.util.LkmSelection
|
import com.sukisu.ultra.ui.util.LkmSelection
|
||||||
import com.sukisu.ultra.ui.util.getAvailablePartitions
|
import com.sukisu.ultra.ui.util.getAvailablePartitions
|
||||||
import com.sukisu.ultra.ui.util.getCurrentKmi
|
import com.sukisu.ultra.ui.util.getCurrentKmi
|
||||||
@@ -84,6 +94,11 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons
|
|||||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
import top.yukonga.miuix.kmp.icon.icons.useful.Edit
|
import top.yukonga.miuix.kmp.icon.icons.useful.Edit
|
||||||
import top.yukonga.miuix.kmp.icon.icons.useful.Move
|
import top.yukonga.miuix.kmp.icon.icons.useful.Move
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import top.yukonga.miuix.kmp.utils.getWindowSize
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
@@ -94,9 +109,18 @@ import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
|||||||
* @author weishu
|
* @author weishu
|
||||||
* @date 2024/3/12.
|
* @date 2024/3/12.
|
||||||
*/
|
*/
|
||||||
|
enum class KpmPatchOption {
|
||||||
|
FOLLOW_KERNEL,
|
||||||
|
PATCH_KPM,
|
||||||
|
UNDO_PATCH_KPM
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
fun InstallScreen(navigator: DestinationsNavigator) {
|
fun InstallScreen(
|
||||||
|
navigator: DestinationsNavigator,
|
||||||
|
preselectedKernelUri: String? = null
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var installMethod by remember {
|
var installMethod by remember {
|
||||||
mutableStateOf<InstallMethod?>(null)
|
mutableStateOf<InstallMethod?>(null)
|
||||||
@@ -106,26 +130,110 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
|||||||
mutableStateOf<LkmSelection>(LkmSelection.KmiNone)
|
mutableStateOf<LkmSelection>(LkmSelection.KmiNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
|
||||||
|
var showSlotSelectionDialog by remember { mutableStateOf(false) }
|
||||||
|
var showKpmPatchDialog by remember { mutableStateOf(false) }
|
||||||
|
var tempKernelUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
val kernelVersion = getKernelVersion()
|
||||||
|
val isGKI = kernelVersion.isGKI()
|
||||||
|
val isAbDevice = produceState(initialValue = false) {
|
||||||
|
value = isAbDevice()
|
||||||
|
}.value
|
||||||
|
|
||||||
var partitionSelectionIndex by remember { mutableIntStateOf(0) }
|
var partitionSelectionIndex by remember { mutableIntStateOf(0) }
|
||||||
var partitionsState by remember { mutableStateOf<List<String>>(emptyList()) }
|
var partitionsState by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var hasCustomSelected by remember { mutableStateOf(false) }
|
var hasCustomSelected by remember { mutableStateOf(false) }
|
||||||
|
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
|
||||||
|
|
||||||
|
// 处理预选的内核文件
|
||||||
|
LaunchedEffect(preselectedKernelUri) {
|
||||||
|
preselectedKernelUri?.let { uriString ->
|
||||||
|
try {
|
||||||
|
val preselectedUri = uriString.toUri()
|
||||||
|
val horizonMethod = InstallMethod.HorizonKernel(
|
||||||
|
uri = preselectedUri,
|
||||||
|
summary = horizonKernelSummary
|
||||||
|
)
|
||||||
|
installMethod = horizonMethod
|
||||||
|
tempKernelUri = preselectedUri
|
||||||
|
if (isAbDevice) {
|
||||||
|
showSlotSelectionDialog = true
|
||||||
|
} else {
|
||||||
|
showKpmPatchDialog = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val onInstall = {
|
val onInstall = {
|
||||||
installMethod?.let { method ->
|
installMethod?.let { method ->
|
||||||
val isOta = method is InstallMethod.DirectInstallToInactiveSlot
|
when (method) {
|
||||||
val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex)
|
is InstallMethod.HorizonKernel -> {
|
||||||
val flashIt = FlashIt.FlashBoot(
|
method.uri?.let { uri ->
|
||||||
boot = if (method is InstallMethod.SelectFile) method.uri else null,
|
navigator.navigate(
|
||||||
lkm = lkmSelection,
|
KernelFlashScreenDestination(
|
||||||
ota = isOta,
|
kernelUri = uri,
|
||||||
partition = partitionSelection
|
selectedSlot = method.slot,
|
||||||
)
|
kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM,
|
||||||
navigator.navigate(FlashScreenDestination(flashIt)) {
|
kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM
|
||||||
launchSingleTop = true
|
)
|
||||||
|
) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val isOta = method is InstallMethod.DirectInstallToInactiveSlot
|
||||||
|
val partitionSelection = partitionsState.getOrNull(partitionSelectionIndex)
|
||||||
|
val flashIt = FlashIt.FlashBoot(
|
||||||
|
boot = if (method is InstallMethod.SelectFile) method.uri else null,
|
||||||
|
lkm = lkmSelection,
|
||||||
|
ota = isOta,
|
||||||
|
partition = partitionSelection
|
||||||
|
)
|
||||||
|
navigator.navigate(FlashScreenDestination(flashIt)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 槽位选择对话框
|
||||||
|
if (showSlotSelectionDialog && isAbDevice) {
|
||||||
|
SlotSelectionDialog(
|
||||||
|
show = true,
|
||||||
|
onDismiss = { showSlotSelectionDialog = false },
|
||||||
|
onSlotSelected = { slot ->
|
||||||
|
showSlotSelectionDialog = false
|
||||||
|
val horizonMethod = InstallMethod.HorizonKernel(
|
||||||
|
uri = tempKernelUri,
|
||||||
|
slot = slot,
|
||||||
|
summary = horizonKernelSummary
|
||||||
|
)
|
||||||
|
installMethod = horizonMethod
|
||||||
|
// 槽位选择后,显示 KPM 补丁选择对话框
|
||||||
|
showKpmPatchDialog = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPM补丁选择对话框
|
||||||
|
if (showKpmPatchDialog) {
|
||||||
|
KpmPatchSelectionDialog(
|
||||||
|
show = true,
|
||||||
|
currentOption = kpmPatchOption,
|
||||||
|
onDismiss = { showKpmPatchDialog = false },
|
||||||
|
onOptionSelected = { option ->
|
||||||
|
kpmPatchOption = option
|
||||||
|
showKpmPatchDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() }
|
val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() }
|
||||||
|
|
||||||
val showChooseKmiDialog = rememberSaveable { mutableStateOf(false) }
|
val showChooseKmiDialog = rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -137,7 +245,7 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val onClickNext = {
|
val onClickNext = {
|
||||||
if (lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) {
|
if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank() && installMethod !is InstallMethod.HorizonKernel) {
|
||||||
// no lkm file selected and cannot get current kmi
|
// no lkm file selected and cannot get current kmi
|
||||||
showChooseKmiDialog.value = true
|
showChooseKmiDialog.value = true
|
||||||
chooseKmiDialog
|
chooseKmiDialog
|
||||||
@@ -174,8 +282,8 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
|||||||
val scrollBehavior = MiuixScrollBehavior()
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val hazeStyle = HazeStyle(
|
val hazeStyle = HazeStyle(
|
||||||
backgroundColor = MiuixTheme.colorScheme.background,
|
backgroundColor = colorScheme.background,
|
||||||
tint = HazeTint(MiuixTheme.colorScheme.background.copy(0.8f))
|
tint = HazeTint(colorScheme.background.copy(0.8f))
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -207,9 +315,22 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
SelectInstallMethod { method ->
|
SelectInstallMethod(
|
||||||
installMethod = method
|
onSelected = { method ->
|
||||||
}
|
if (method is InstallMethod.HorizonKernel && method.uri != null) {
|
||||||
|
if (isAbDevice) {
|
||||||
|
tempKernelUri = method.uri
|
||||||
|
showSlotSelectionDialog = true
|
||||||
|
} else {
|
||||||
|
installMethod = method
|
||||||
|
showKpmPatchDialog = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
installMethod = method
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isAbDevice = isAbDevice
|
||||||
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot,
|
visible = installMethod is InstallMethod.DirectInstall || installMethod is InstallMethod.DirectInstallToInactiveSlot,
|
||||||
@@ -256,29 +377,89 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Card(
|
// LKM 上传选项(仅 GKI)
|
||||||
modifier = Modifier
|
if (isGKI) {
|
||||||
.fillMaxWidth()
|
Card(
|
||||||
.padding(top = 12.dp),
|
modifier = Modifier
|
||||||
) {
|
.fillMaxWidth()
|
||||||
SuperArrow(
|
.padding(top = 12.dp),
|
||||||
title = stringResource(id = R.string.install_upload_lkm_file),
|
) {
|
||||||
summary = (lkmSelection as? LkmSelection.LkmUri)?.let {
|
SuperArrow(
|
||||||
stringResource(
|
title = stringResource(id = R.string.install_upload_lkm_file),
|
||||||
id = R.string.selected_lkm,
|
summary = (lkmSelection as? LkmSelection.LkmUri)?.let {
|
||||||
it.uri.lastPathSegment ?: "(file)"
|
stringResource(
|
||||||
)
|
id = R.string.selected_lkm,
|
||||||
},
|
it.uri.lastPathSegment ?: "(file)"
|
||||||
onClick = onLkmUpload,
|
)
|
||||||
leftAction = {
|
},
|
||||||
Icon(
|
onClick = onLkmUpload,
|
||||||
MiuixIcons.Useful.Move,
|
leftAction = {
|
||||||
tint = colorScheme.onSurface,
|
Icon(
|
||||||
modifier = Modifier.padding(end = 16.dp),
|
MiuixIcons.Useful.Move,
|
||||||
contentDescription = null
|
tint = colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyKernel3 相关信息显示
|
||||||
|
(installMethod as? InstallMethod.HorizonKernel)?.let { method ->
|
||||||
|
if (method.slot != null) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
) {
|
||||||
|
SuperArrow(
|
||||||
|
title = stringResource(
|
||||||
|
id = R.string.selected_slot,
|
||||||
|
if (method.slot == "a") stringResource(id = R.string.slot_a)
|
||||||
|
else stringResource(id = R.string.slot_b)
|
||||||
|
),
|
||||||
|
onClick = {},
|
||||||
|
leftAction = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SdStorage,
|
||||||
|
tint = colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// KPM 状态显示
|
||||||
|
if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
) {
|
||||||
|
SuperArrow(
|
||||||
|
title = when (kpmPatchOption) {
|
||||||
|
KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled)
|
||||||
|
KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled)
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
onClick = {},
|
||||||
|
leftAction = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Security,
|
||||||
|
tint = if (kpmPatchOption == KpmPatchOption.PATCH_KPM)
|
||||||
|
colorScheme.primary
|
||||||
|
else
|
||||||
|
colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -322,19 +503,27 @@ sealed class InstallMethod {
|
|||||||
get() = R.string.install_inactive_slot
|
get() = R.string.install_inactive_slot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class HorizonKernel(
|
||||||
|
val uri: Uri? = null,
|
||||||
|
val slot: String? = null,
|
||||||
|
@get:StringRes override val label: Int = R.string.horizon_kernel,
|
||||||
|
override val summary: String? = null
|
||||||
|
) : InstallMethod()
|
||||||
|
|
||||||
abstract val label: Int
|
abstract val label: Int
|
||||||
open val summary: String? = null
|
open val summary: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
|
private fun SelectInstallMethod(
|
||||||
|
onSelected: (InstallMethod) -> Unit = {},
|
||||||
|
isAbDevice: Boolean = false
|
||||||
|
) {
|
||||||
val rootAvailable = rootAvailable()
|
val rootAvailable = rootAvailable()
|
||||||
val isAbDevice = produceState(initialValue = false) {
|
|
||||||
value = isAbDevice()
|
|
||||||
}.value
|
|
||||||
val defaultPartitionName = produceState(initialValue = "boot") {
|
val defaultPartitionName = produceState(initialValue = "boot") {
|
||||||
value = getDefaultPartition()
|
value = getDefaultPartition()
|
||||||
}.value
|
}.value
|
||||||
|
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
|
||||||
val selectFileTip = stringResource(
|
val selectFileTip = stringResource(
|
||||||
id = R.string.select_file_tip, defaultPartitionName
|
id = R.string.select_file_tip, defaultPartitionName
|
||||||
)
|
)
|
||||||
@@ -345,17 +534,26 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
|
|||||||
if (isAbDevice) {
|
if (isAbDevice) {
|
||||||
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
|
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
|
||||||
}
|
}
|
||||||
|
radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary))
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
|
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
|
||||||
|
var currentSelectingMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
||||||
|
|
||||||
val selectImageLauncher = rememberLauncherForActivityResult(
|
val selectImageLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
) {
|
) {
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) {
|
||||||
it.data?.data?.let { uri ->
|
it.data?.data?.let { uri ->
|
||||||
val option = InstallMethod.SelectFile(uri, summary = selectFileTip)
|
val option = when (currentSelectingMethod) {
|
||||||
selectedOption = option
|
is InstallMethod.SelectFile -> InstallMethod.SelectFile(uri, summary = selectFileTip)
|
||||||
onSelected(option)
|
is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(uri, summary = horizonKernelSummary)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
option?.let { opt ->
|
||||||
|
selectedOption = opt
|
||||||
|
onSelected(opt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,11 +568,12 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) {
|
|||||||
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
|
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
|
||||||
|
|
||||||
val onClick = { option: InstallMethod ->
|
val onClick = { option: InstallMethod ->
|
||||||
|
currentSelectingMethod = option
|
||||||
when (option) {
|
when (option) {
|
||||||
is InstallMethod.SelectFile -> {
|
is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> {
|
||||||
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||||
type = "application/octet-stream"
|
type = "application/*"
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream", "application/zip"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +649,121 @@ private fun TopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KpmPatchSelectionDialog(
|
||||||
|
show: Boolean,
|
||||||
|
currentOption: KpmPatchOption,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onOptionSelected: (KpmPatchOption) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedOption by remember { mutableStateOf(currentOption) }
|
||||||
|
val showDialog = remember { mutableStateOf(show) }
|
||||||
|
|
||||||
|
LaunchedEffect(show) {
|
||||||
|
showDialog.value = show
|
||||||
|
if (show) {
|
||||||
|
selectedOption = currentOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SuperDialog(
|
||||||
|
show = showDialog,
|
||||||
|
insideMargin = DpSize(0.dp, 0.dp),
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog.value = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp)
|
||||||
|
) {
|
||||||
|
// 标题
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
text = stringResource(id = R.string.kpm_patch_options),
|
||||||
|
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
// 描述
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
text = stringResource(id = R.string.kpm_patch_description),
|
||||||
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 选项列表
|
||||||
|
val options = listOf(
|
||||||
|
KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file),
|
||||||
|
KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch),
|
||||||
|
KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch)
|
||||||
|
)
|
||||||
|
|
||||||
|
options.forEach { (option, title) ->
|
||||||
|
SuperArrow(
|
||||||
|
title = title,
|
||||||
|
onClick = {
|
||||||
|
selectedOption = option
|
||||||
|
},
|
||||||
|
leftAction = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Security,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (selectedOption == option) {
|
||||||
|
colorScheme.primary
|
||||||
|
} else {
|
||||||
|
colorScheme.onSurfaceVariantSummary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 按钮行
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.cancel),
|
||||||
|
onClick = {
|
||||||
|
showDialog.value = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.ok),
|
||||||
|
onClick = {
|
||||||
|
onOptionSelected(selectedOption)
|
||||||
|
showDialog.value = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun isKoFile(context: Context, uri: Uri): Boolean {
|
private fun isKoFile(context: Context, uri: Uri): Boolean {
|
||||||
val seg = uri.lastPathSegment ?: ""
|
val seg = uri.lastPathSegment ?: ""
|
||||||
if (seg.endsWith(".ko", ignoreCase = true)) return true
|
if (seg.endsWith(".ko", ignoreCase = true)) return true
|
||||||
|
|||||||
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_light">浅色</string>
|
||||||
<string name="theme_dark">深色</string>
|
<string name="theme_dark">深色</string>
|
||||||
<string name="theme_follow_system">跟随系统</string>
|
<string name="theme_follow_system">跟随系统</string>
|
||||||
|
<!-- Kernel Flash Progress Related -->
|
||||||
|
<string name="horizon_flash_complete">刷入完成</string>
|
||||||
|
<string name="horizon_preparing">准备中…</string>
|
||||||
|
<string name="horizon_cleaning_files">清理文件…</string>
|
||||||
|
<string name="horizon_copying_files">复制文件…</string>
|
||||||
|
<string name="horizon_extracting_tool">解压刷机工具…</string>
|
||||||
|
<string name="horizon_patching_script">修补刷机脚本…</string>
|
||||||
|
<string name="horizon_flashing">正在刷入内核…</string>
|
||||||
|
<string name="horizon_flash_complete_status">刷入已完成</string>
|
||||||
|
<string name="select_slot_title">选择刷入槽位</string>
|
||||||
|
<string name="select_slot_description">请选择要刷入boot的目标槽位</string>
|
||||||
|
<string name="slot_a">槽位 A</string>
|
||||||
|
<string name="slot_b">槽位 B</string>
|
||||||
|
<string name="selected_slot">已选槽位: %1$s</string>
|
||||||
|
<string name="horizon_getting_original_slot">获取原始槽位</string>
|
||||||
|
<string name="horizon_setting_target_slot">设置指定槽位</string>
|
||||||
|
<string name="horizon_restoring_original_slot">恢复默认槽位</string>
|
||||||
|
<string name="current_slot">当前系统默认槽位:%1$s </string>
|
||||||
|
<string name="horizon_copy_failed">复制失败</string>
|
||||||
|
<string name="horizon_unknown_error">未知错误</string>
|
||||||
|
<string name="flash_failed_message">刷入失败</string>
|
||||||
|
<string name="Lkm_install_methods">LKM 修复/安装</string>
|
||||||
|
<string name="GKI_install_methods">刷入 AnyKernel3</string>
|
||||||
|
<string name="kernel_version_log">内核版本:%1$s</string>
|
||||||
|
<string name="tool_version_log">使用的修补工具:%1$s</string>
|
||||||
|
<string name="configuration">配置</string>
|
||||||
|
<string name="app_settings">应用程序设置</string>
|
||||||
|
<string name="tools">工具</string>
|
||||||
|
<string name="root_required">需要root权限</string>
|
||||||
|
<string name="kpm_patch_options">KPM 补丁</string>
|
||||||
|
<string name="kpm_patch_description">用于添加额外的KPM功能</string>
|
||||||
|
<string name="enable_kpm_patch">KPM 补丁</string>
|
||||||
|
<string name="kpm_patch_switch_description">在刷入前对内核镜像应用KPM补丁</string>
|
||||||
|
<string name="enable_kpm_undo_patch">KPM 撤销补丁</string>
|
||||||
|
<string name="kpm_undo_patch_switch_description">撤销之前应用的KPM补丁</string>
|
||||||
|
<string name="kpm_patch_enabled">KPM 补丁已启用</string>
|
||||||
|
<string name="kpm_undo_patch_enabled">KPM 撤销补丁已启用</string>
|
||||||
|
<string name="kpm_patch_mode">KPM 补丁模式</string>
|
||||||
|
<string name="kpm_undo_patch_mode">KPM 撤销补丁模式</string>
|
||||||
|
<string name="kpm_preparing_tools">准备KPM工具</string>
|
||||||
|
<string name="kpm_applying_patch">应用KPM补丁</string>
|
||||||
|
<string name="kpm_undoing_patch">撤销KPM补丁</string>
|
||||||
|
<string name="kpm_found_image_file">找到镜像文件: %s</string>
|
||||||
|
<string name="kpm_patch_success">KPM 补丁应用成功</string>
|
||||||
|
<string name="kpm_undo_patch_success">KPM 补丁撤销成功</string>
|
||||||
|
<string name="kpm_file_repacked">文件重新打包成功</string>
|
||||||
|
<string name="kpm_extract_zip_failed">解压zip文件失败</string>
|
||||||
|
<string name="kpm_image_file_not_found">未找到镜像文件</string>
|
||||||
|
<string name="kpm_patch_failed">KPM 补丁失败</string>
|
||||||
|
<string name="kpm_undo_patch_failed">KPM 撤销补丁失败</string>
|
||||||
|
<string name="kpm_patch_operation_failed">KPM 补丁操作失败: %s</string>
|
||||||
|
<string name="kpm_follow_kernel_file">跟随内核</string>
|
||||||
|
<string name="kpm_follow_kernel_description">原样使用内核,不进行任何KPM修改</string>
|
||||||
|
<string name="kernel_flashing">内核刷入</string>
|
||||||
|
<string name="horizon_kernel">AnyKernel3 内核</string>
|
||||||
|
<string name="horizon_kernel_summary">刷入AnyKernel3格式的内核zip包</string>
|
||||||
|
<!-- kpm -->
|
||||||
|
<string name="kpm_title">KPM</string>
|
||||||
|
<string name="kpm_empty">当前没有安装内核模块</string>
|
||||||
|
<string name="kpm_version">版本</string>
|
||||||
|
<string name="kpm_author">作者</string>
|
||||||
|
<string name="kpm_uninstall">卸载</string>
|
||||||
|
<string name="kpm_uninstall_success">卸载成功</string>
|
||||||
|
<string name="kpm_uninstall_failed">卸载失败</string>
|
||||||
|
<string name="kpm_install_success">kpm 模块加载成功</string>
|
||||||
|
<string name="kpm_install_failed">kpm 模块加载失败</string>
|
||||||
|
<string name="kpm_args">参数</string>
|
||||||
|
<string name="kpm_control">执行</string>
|
||||||
|
<string name="home_kpm_version">KPM 版本</string>
|
||||||
|
<string name="close_notice">关闭</string>
|
||||||
|
<string name="kernel_module_notice">以下内核模块功能由 KernelPatch 开发,并经过修改后加入了 SukiSU Ultra 的内核模块功能</string>
|
||||||
|
<string name="home_ContributionCard_kernelsu">SukiSU Ultra 期待</string>
|
||||||
|
<string name="kpm_control_success">成功</string>
|
||||||
|
<string name="kpm_control_failed">失败</string>
|
||||||
|
<string name="home_click_to_ContributionCard_kernelsu">SukiSU Ultra 在未来将是 KSU 的相对独立分支,但我们仍然感谢官方 KernelSU 和 MKSU 等的贡献!</string>
|
||||||
|
<string name="not_supported">不支持</string>
|
||||||
|
<string name="supported">支持</string>
|
||||||
|
<string name="kernel_patched">内核未修补</string>
|
||||||
|
<string name="kernel_not_enabled">内核未配置</string>
|
||||||
|
<string name="custom_settings">自定义设置</string>
|
||||||
|
<string name="kpm_install_mode">KPM 安装</string>
|
||||||
|
<string name="kpm_install_mode_load">加载</string>
|
||||||
|
<string name="kpm_install_mode_embed">嵌入</string>
|
||||||
|
<string name="kpm_install_mode_description">请选择:%1$s 模块安装模式\n\n加载:临时加载模块\n嵌入:永久安装到系统中</string>
|
||||||
|
<string name="invalid_file_type">文件类型不正确!请选择 .kpm 文件</string>
|
||||||
|
<string name="confirm_uninstall_title_with_filename">卸载</string>
|
||||||
|
<string name="confirm_uninstall_content">以下 KPM 将被卸载:%s</string>
|
||||||
|
<string name="snackbar_failed_to_check_module_file">无法检查模块文件是否存在</string>
|
||||||
|
<string name="cancel">取消</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -250,4 +250,93 @@
|
|||||||
<string name="theme_light">Light</string>
|
<string name="theme_light">Light</string>
|
||||||
<string name="theme_dark">Dark</string>
|
<string name="theme_dark">Dark</string>
|
||||||
<string name="theme_follow_system">Follow System</string>
|
<string name="theme_follow_system">Follow System</string>
|
||||||
|
<!-- Kernel Flash Progress Related -->
|
||||||
|
<string name="horizon_flash_complete">Flash Complete</string>
|
||||||
|
<string name="horizon_preparing">Preparing…</string>
|
||||||
|
<string name="horizon_cleaning_files">Cleaning files…</string>
|
||||||
|
<string name="horizon_copying_files">Copying files…</string>
|
||||||
|
<string name="horizon_extracting_tool">Extracting flash tool…</string>
|
||||||
|
<string name="horizon_patching_script">Patching flash script…</string>
|
||||||
|
<string name="horizon_flashing">Flashing kernel…</string>
|
||||||
|
<string name="horizon_flash_complete_status">Flash completed</string>
|
||||||
|
<string name="select_slot_title">Select Flash Slot</string>
|
||||||
|
<string name="select_slot_description">Please select the target slot for flashing boot</string>
|
||||||
|
<string name="slot_a">Slot A</string>
|
||||||
|
<string name="slot_b">Slot B</string>
|
||||||
|
<string name="selected_slot">Selected slot: %1$s</string>
|
||||||
|
<string name="horizon_getting_original_slot">Getting the original slot</string>
|
||||||
|
<string name="horizon_setting_target_slot">Setting the specified slot</string>
|
||||||
|
<string name="horizon_restoring_original_slot">Restore Default Slot</string>
|
||||||
|
<string name="current_slot">Current system default slot:%1$s </string>
|
||||||
|
<string name="horizon_copy_failed">Copy failed</string>
|
||||||
|
<string name="horizon_unknown_error">Unknown error</string>
|
||||||
|
<string name="flash_failed_message">Flash failed</string>
|
||||||
|
<string name="Lkm_install_methods">LKM repair/installation</string>
|
||||||
|
<string name="GKI_install_methods">Flashing AnyKernel3</string>
|
||||||
|
<string name="kernel_version_log">Kernel version:%1$s</string>
|
||||||
|
<string name="tool_version_log">Using the patching tool:%1$s</string>
|
||||||
|
<string name="configuration">Configure</string>
|
||||||
|
<string name="app_settings">Application Settings</string>
|
||||||
|
<string name="tools">Tools</string>
|
||||||
|
<string name="root_required">Requires root privileges</string>
|
||||||
|
<string name="kpm_patch_options">KPM Patch</string>
|
||||||
|
<string name="kpm_patch_description">For adding additional KPM features</string>
|
||||||
|
<string name="enable_kpm_patch">KPM Patch</string>
|
||||||
|
<string name="kpm_patch_switch_description">Apply KPM patch to kernel image before flashing</string>
|
||||||
|
<string name="enable_kpm_undo_patch">KPM Undo Patch</string>
|
||||||
|
<string name="kpm_undo_patch_switch_description">Undo previously applied KPM patch</string>
|
||||||
|
<string name="kpm_patch_enabled">KPM patch enabled</string>
|
||||||
|
<string name="kpm_undo_patch_enabled">KPM undo patch enabled</string>
|
||||||
|
<string name="kpm_patch_mode">KPM Patch Mode</string>
|
||||||
|
<string name="kpm_undo_patch_mode">KPM Undo Patch Mode</string>
|
||||||
|
<string name="kpm_preparing_tools">Preparing KPM tools</string>
|
||||||
|
<string name="kpm_applying_patch">Applying KPM patch</string>
|
||||||
|
<string name="kpm_undoing_patch">Undoing KPM patch</string>
|
||||||
|
<string name="kpm_found_image_file">Found Image file: %s</string>
|
||||||
|
<string name="kpm_patch_success">KPM patch applied successfully</string>
|
||||||
|
<string name="kpm_undo_patch_success">KPM patch undone successfully</string>
|
||||||
|
<string name="kpm_file_repacked">File repacked successfully</string>
|
||||||
|
<string name="kpm_extract_zip_failed">Failed to extract zip file</string>
|
||||||
|
<string name="kpm_image_file_not_found">Image file not found</string>
|
||||||
|
<string name="kpm_patch_failed">KPM patch failed</string>
|
||||||
|
<string name="kpm_undo_patch_failed">KPM undo patch failed</string>
|
||||||
|
<string name="kpm_patch_operation_failed">KPM patch operation failed: %s</string>
|
||||||
|
<string name="kpm_follow_kernel_file">Follow Kernel</string>
|
||||||
|
<string name="kpm_follow_kernel_description">Use kernel as-is without any KPM modifications</string>
|
||||||
|
<string name="kernel_flashing">Kernel Flashing</string>
|
||||||
|
<string name="horizon_kernel">AnyKernel3 Kernel</string>
|
||||||
|
<string name="horizon_kernel_summary">Flash AnyKernel3 format kernel zip</string>
|
||||||
|
<!-- kpm -->
|
||||||
|
<string name="kpm_title">KPM</string>
|
||||||
|
<string name="kpm_empty">No installed kernel modules at this time</string>
|
||||||
|
<string name="kpm_version">Version</string>
|
||||||
|
<string name="kpm_author">Author</string>
|
||||||
|
<string name="kpm_uninstall">Uninstall</string>
|
||||||
|
<string name="kpm_uninstall_success">Uninstalled successfully</string>
|
||||||
|
<string name="kpm_uninstall_failed">Failed to uninstall</string>
|
||||||
|
<string name="kpm_install_success">Load of kpm module successful</string>
|
||||||
|
<string name="kpm_install_failed">Load of kpm module failed</string>
|
||||||
|
<string name="kpm_args">Parameters</string>
|
||||||
|
<string name="kpm_control">Execute</string>
|
||||||
|
<string name="home_kpm_version">KPM Version</string>
|
||||||
|
<string name="close_notice">Close</string>
|
||||||
|
<string name="kernel_module_notice">The following kernel module functions were developed by KernelPatch and modified to include the kernel module functions of SukiSU Ultra</string>
|
||||||
|
<string name="home_ContributionCard_kernelsu">SukiSU Ultra Look forward to</string>
|
||||||
|
<string name="kpm_control_success">Success</string>
|
||||||
|
<string name="kpm_control_failed">Failed</string>
|
||||||
|
<string name="home_click_to_ContributionCard_kernelsu">SukiSU Ultra will be a relatively independent branch of KSU in the future, but we still appreciate the official KernelSU and MKSU etc. for their contributions!</string>
|
||||||
|
<string name="not_supported">Unsupported</string>
|
||||||
|
<string name="supported">Supported</string>
|
||||||
|
<string name="kernel_patched">Kernel not patched</string>
|
||||||
|
<string name="kernel_not_enabled">Kernel not configured</string>
|
||||||
|
<string name="custom_settings">Custom settings</string>
|
||||||
|
<string name="kpm_install_mode">KPM Install</string>
|
||||||
|
<string name="kpm_install_mode_load">Load</string>
|
||||||
|
<string name="kpm_install_mode_embed">Embed</string>
|
||||||
|
<string name="kpm_install_mode_description">Please select: %1\$s Module Installation Mode \n\nLoad: Temporarily load the module \nEmbedded: Permanently install into the system</string>
|
||||||
|
<string name="invalid_file_type">Incorrect file type! Please select .kpm file</string>
|
||||||
|
<string name="confirm_uninstall_title_with_filename">Uninstall</string>
|
||||||
|
<string name="confirm_uninstall_content">The following KPM will be uninstalled: %s</string>
|
||||||
|
<string name="snackbar_failed_to_check_module_file">Unable to check if module file exists</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ cmaker = "1.2"
|
|||||||
miuix = "0.6.1"
|
miuix = "0.6.1"
|
||||||
haze = "1.7.0"
|
haze = "1.7.0"
|
||||||
capsule = "2.1.1"
|
capsule = "2.1.1"
|
||||||
|
documentfile = "1.1.0"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
agp-app = { id = "com.android.application", version.ref = "agp" }
|
agp-app = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -74,3 +75,4 @@ miuix = { module = "top.yukonga.miuix.kmp:miuix-android", version.ref = "miuix"
|
|||||||
haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" }
|
||||||
|
|
||||||
capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" }
|
capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" }
|
||||||
|
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||||
Reference in New Issue
Block a user