Manager: Added KPM patching functionality support. close #372
- Integrated KPM patching logic into KernelFlash and display KPM patching status. - Updated Install to support KPM patching options. - Implemented local and remote downloads for KPM tools. Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
This commit is contained in:
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.
@@ -0,0 +1,364 @@
|
||||
package com.sukisu.ultra.network
|
||||
|
||||
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 {
|
||||
com.sukisu.ultra.utils.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -66,6 +67,8 @@ import com.sukisu.ultra.ui.util.*
|
||||
fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
var installMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
||||
var lkmSelection by remember { mutableStateOf<LkmSelection>(LkmSelection.KmiNone) }
|
||||
var kpmPatchEnabled by remember { mutableStateOf(false) }
|
||||
var kpmUndoPatch by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
var showRebootDialog by remember { mutableStateOf(false) }
|
||||
var showSlotSelectionDialog by remember { mutableStateOf(false) }
|
||||
@@ -102,7 +105,9 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
navigator.navigate(
|
||||
KernelFlashScreenDestination(
|
||||
kernelUri = uri,
|
||||
selectedSlot = method.slot
|
||||
selectedSlot = method.slot,
|
||||
kpmPatchEnabled = kpmPatchEnabled,
|
||||
kpmUndoPatch = kpmUndoPatch
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -154,7 +159,6 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
@@ -188,7 +192,12 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
} else {
|
||||
installMethod = method
|
||||
}
|
||||
}
|
||||
},
|
||||
kpmPatchEnabled = kpmPatchEnabled,
|
||||
onKpmPatchChanged = { kpmPatchEnabled = it },
|
||||
kpmUndoPatch = kpmUndoPatch,
|
||||
onKpmUndoPatchChanged = { kpmUndoPatch = it },
|
||||
selectedMethod = installMethod
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -247,6 +256,33 @@ fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// KPM 状态显示卡片
|
||||
if (kpmPatchEnabled || kpmUndoPatch) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.shadow(
|
||||
elevation = cardElevation,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (kpmUndoPatch) R.string.kpm_undo_patch_enabled
|
||||
else R.string.kpm_patch_enabled
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
@@ -327,7 +363,12 @@ sealed class InstallMethod {
|
||||
@Composable
|
||||
private fun SelectInstallMethod(
|
||||
isGKI: Boolean = false,
|
||||
onSelected: (InstallMethod) -> Unit = {}
|
||||
onSelected: (InstallMethod) -> Unit = {},
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
onKpmPatchChanged: (Boolean) -> Unit = {},
|
||||
kpmUndoPatch: Boolean = false,
|
||||
onKpmUndoPatchChanged: (Boolean) -> Unit = {},
|
||||
selectedMethod: InstallMethod? = null
|
||||
) {
|
||||
val rootAvailable = rootAvailable()
|
||||
val isAbDevice = isAbDevice()
|
||||
@@ -335,9 +376,9 @@ private fun SelectInstallMethod(
|
||||
val selectFileTip = stringResource(
|
||||
id = R.string.select_file_tip,
|
||||
if (isInitBoot()) {
|
||||
"init_boot / vendor_boot ${stringResource(R.string.select_file_tip_vendor)}"
|
||||
} else {
|
||||
"boot"
|
||||
"init_boot / vendor_boot ${stringResource(R.string.select_file_tip_vendor)}"
|
||||
} else {
|
||||
"boot"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -537,7 +578,7 @@ private fun SelectInstallMethod(
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.padding(bottom = if (selectedMethod is InstallMethod.HorizonKernel) 0.dp else 12.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
) {
|
||||
MaterialTheme(
|
||||
@@ -635,6 +676,137 @@ private fun SelectInstallMethod(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPM 修补选项卡片
|
||||
if (selectedMethod is InstallMethod.HorizonKernel && selectedMethod.uri != null) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.kpm_patch_options),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(R.string.kpm_patch_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
) {
|
||||
// KPM 修补开关
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable {
|
||||
if (!kpmPatchEnabled) {
|
||||
onKpmPatchChanged(true)
|
||||
if (kpmUndoPatch) onKpmUndoPatchChanged(false)
|
||||
} else {
|
||||
onKpmPatchChanged(false)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Switch(
|
||||
checked = kpmPatchEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
onKpmPatchChanged(enabled)
|
||||
if (enabled && kpmUndoPatch) onKpmUndoPatchChanged(false)
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.enable_kpm_patch),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kpm_patch_switch_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// KPM 撤销修补开关
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable {
|
||||
if (!kpmUndoPatch) {
|
||||
onKpmUndoPatchChanged(true)
|
||||
if (kpmPatchEnabled) onKpmPatchChanged(false)
|
||||
} else {
|
||||
onKpmUndoPatchChanged(false)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Switch(
|
||||
checked = kpmUndoPatch,
|
||||
onCheckedChange = { enabled ->
|
||||
onKpmUndoPatchChanged(enabled)
|
||||
if (enabled && kpmPatchEnabled) onKpmPatchChanged(false)
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.tertiary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.enable_kpm_undo_patch),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kpm_undo_patch_switch_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.network.RemoteToolsDownloader
|
||||
import com.sukisu.ultra.ui.util.rootAvailable
|
||||
import com.sukisu.ultra.utils.AssetsUtil
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -16,6 +18,8 @@ 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
|
||||
|
||||
|
||||
/**
|
||||
@@ -78,14 +82,18 @@ class HorizonKernelState {
|
||||
class HorizonKernelWorker(
|
||||
private val context: Context,
|
||||
private val state: HorizonKernelState,
|
||||
private val slot: String? = null
|
||||
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
|
||||
@@ -97,6 +105,7 @@ class HorizonKernelWorker(
|
||||
|
||||
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))
|
||||
@@ -121,6 +130,20 @@ class HorizonKernelWorker(
|
||||
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()
|
||||
@@ -162,6 +185,206 @@ class HorizonKernelWorker(
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行KMP修补操作
|
||||
*/
|
||||
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
|
||||
|
||||
state.addLog(context.getString(R.string.kpm_found_image_file, imageFile))
|
||||
|
||||
// 复制KMP工具到Image文件所在目录
|
||||
runCommand(true, "cp $workDir/kptools $imageDir/")
|
||||
runCommand(true, "cp $workDir/kpimg $imageDir/")
|
||||
|
||||
// 执行KMP修补命令
|
||||
val patchCommand = if (kpmUndoPatch) {
|
||||
"cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i Image -k kpimg -o oImage && mv oImage Image"
|
||||
} else {
|
||||
"cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i Image -k kpimg -o oImage && mv oImage Image"
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
// 清理KMP工具文件
|
||||
runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage")
|
||||
|
||||
// 使用Java方式重新打包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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +399,7 @@ class HorizonKernelWorker(
|
||||
|
||||
private fun cleanup() {
|
||||
runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete")
|
||||
runCommand(false, "rm -rf $workDir")
|
||||
}
|
||||
|
||||
private fun copy() {
|
||||
@@ -195,6 +419,7 @@ class HorizonKernelWorker(
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
private fun patch() {
|
||||
val kernelVersion = runCommandGetOutput("cat /proc/version")
|
||||
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
|
||||
|
||||
@@ -54,6 +54,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -66,7 +68,9 @@ private object KernelFlashStateHolder {
|
||||
fun KernelFlashScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
kernelUri: Uri,
|
||||
selectedSlot: String? = null
|
||||
selectedSlot: String? = null,
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -79,13 +83,17 @@ fun KernelFlashScreen(
|
||||
val horizonKernelState = remember {
|
||||
if (KernelFlashStateHolder.currentState != null &&
|
||||
KernelFlashStateHolder.currentUri == kernelUri &&
|
||||
KernelFlashStateHolder.currentSlot == selectedSlot) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -107,7 +115,9 @@ fun KernelFlashScreen(
|
||||
val worker = HorizonKernelWorker(
|
||||
context = context,
|
||||
state = horizonKernelState,
|
||||
slot = selectedSlot
|
||||
slot = selectedSlot,
|
||||
kpmPatchEnabled = kpmPatchEnabled,
|
||||
kpmUndoPatch = kpmUndoPatch
|
||||
)
|
||||
worker.uri = kernelUri
|
||||
worker.setOnFlashCompleteListener(onFlashComplete)
|
||||
@@ -147,6 +157,8 @@ fun KernelFlashScreen(
|
||||
KernelFlashStateHolder.currentState = null
|
||||
KernelFlashStateHolder.currentUri = null
|
||||
KernelFlashStateHolder.currentSlot = null
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
}
|
||||
navigator.popBackStack()
|
||||
@@ -216,7 +228,7 @@ fun KernelFlashScreen(
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) {
|
||||
FlashProgressIndicator(flashState)
|
||||
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -239,7 +251,11 @@ fun KernelFlashScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlashProgressIndicator(flashState: FlashState) {
|
||||
private fun FlashProgressIndicator(
|
||||
flashState: FlashState,
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
val progressColor = when {
|
||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
||||
@@ -298,6 +314,17 @@ private fun FlashProgressIndicator(flashState: FlashState) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (flashState.currentStep.isNotEmpty()) {
|
||||
|
||||
@@ -620,4 +620,34 @@
|
||||
<string name="module_signature_invalid_message">未经签名的模块可能不完整。为了对设备进行保护,已阻止安装此模块。</string>
|
||||
<string name="module_signature_verification_failed">未经签名的模块可能不完整。你想安装来自未知发布者的模块吗?</string>
|
||||
<string name="home_hook_type">钩子类型</string>
|
||||
<!-- KPM修补相关字符串 -->
|
||||
<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>
|
||||
<!-- KPM工作流程相关 -->
|
||||
<string name="kpm_preparing_tools">准备KPM修补工具</string>
|
||||
<string name="kpm_applying_patch">正在应用KPM修补</string>
|
||||
<string name="kpm_undoing_patch">正在撤销KPM修补</string>
|
||||
<string name="kpm_tools_prepared">KPM工具准备完成</string>
|
||||
<string name="kpm_found_image_file">找到Image文件: %s</string>
|
||||
<string name="kpm_patch_success">KPM修补成功</string>
|
||||
<string name="kpm_undo_patch_success">KPM撤销修补成功</string>
|
||||
<string name="kpm_file_repacked">文件重新打包完成</string>
|
||||
<!-- KPM错误信息 -->
|
||||
<string name="kpm_extract_kptools_failed">提取kptools工具失败</string>
|
||||
<string name="kpm_extract_kpimg_failed">提取kpimg文件失败</string>
|
||||
<string name="kpm_prepare_tools_failed">准备KPM工具失败: %s</string>
|
||||
<string name="kpm_extract_zip_failed">解压压缩包失败</string>
|
||||
<string name="kpm_image_file_not_found">未找到Image文件</string>
|
||||
<string name="kpm_patch_failed">KPM修补失败</string>
|
||||
<string name="kpm_undo_patch_failed">KPM撤销修补失败</string>
|
||||
<string name="kpm_repack_zip_failed">重新打包压缩文件失败</string>
|
||||
<string name="kpm_patch_operation_failed">KPM修补操作失败: %s</string>
|
||||
</resources>
|
||||
|
||||
@@ -628,4 +628,34 @@ Important Note:\n
|
||||
<string name="module_signature_invalid_message">Unsigned modules may be incomplete. To protect your device, installation of this module has been blocked</string>
|
||||
<string name="module_signature_verification_failed">Unsigned modules may be incomplete. Do you want to allow the following module from an unknown publisher to install in this device?</string>
|
||||
<string name="home_hook_type">Hook type</string>
|
||||
<!-- KPM patching related strings -->
|
||||
<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>
|
||||
<!-- KPM workflow related -->
|
||||
<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_tools_prepared">KPM tools prepared</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>
|
||||
<!-- KPM error messages -->
|
||||
<string name="kpm_extract_kptools_failed">Failed to extract kptools</string>
|
||||
<string name="kpm_extract_kpimg_failed">Failed to extract kpimg file</string>
|
||||
<string name="kpm_prepare_tools_failed">Failed to prepare KPM tools: %s</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_repack_zip_failed">Failed to repack zip file</string>
|
||||
<string name="kpm_patch_operation_failed">KPM patch operation failed: %s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user