diff --git a/manager/app/build.gradle.kts b/manager/app/build.gradle.kts index 5961b20c..820d3c41 100644 --- a/manager/app/build.gradle.kts +++ b/manager/app/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.foundation) + implementation(libs.androidx.documentfile) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/manager/app/src/main/AndroidManifest.xml b/manager/app/src/main/AndroidManifest.xml index 11cda5f2..5bbdbd49 100644 --- a/manager/app/src/main/AndroidManifest.xml +++ b/manager/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> + + @Composable fun InstallScreen(navigator: DestinationsNavigator) { - var installMethod by remember { - mutableStateOf(null) + var installMethod by remember { mutableStateOf(null) } + var lkmSelection by remember { mutableStateOf(LkmSelection.KmiNone) } + val context = LocalContext.current + + var showRebootDialog by remember { mutableStateOf(false) } + + val onFlashComplete = { + showRebootDialog = true } - var lkmSelection by remember { - mutableStateOf(LkmSelection.KmiNone) + if (showRebootDialog) { + RebootDialog( + show = true, + onDismiss = { showRebootDialog = false }, + onConfirm = { + showRebootDialog = false + try { + val process = Runtime.getRuntime().exec("su") + process.outputStream.bufferedWriter().use { writer -> + writer.write("svc power reboot\n") + writer.write("exit\n") + } + } catch (e: Exception) { + Toast.makeText(context, R.string.failed_reboot, Toast.LENGTH_SHORT).show() + } + } + ) } val onInstall = { installMethod?.let { method -> - val flashIt = FlashIt.FlashBoot( - boot = if (method is InstallMethod.SelectFile) method.uri else null, - lkm = lkmSelection, - ota = method is InstallMethod.DirectInstallToInactiveSlot - ) - navigator.navigate(FlashScreenDestination(flashIt)) + when (method) { + is InstallMethod.HorizonKernel -> { + method.uri?.let { uri -> + val worker = HorizonKernelWorker(context) + worker.uri = uri + worker.setOnFlashCompleteListener(onFlashComplete) + worker.start() + } + } + else -> { + val flashIt = FlashIt.FlashBoot( + boot = if (method is InstallMethod.SelectFile) method.uri else null, + lkm = lkmSelection, + ota = method is InstallMethod.DirectInstallToInactiveSlot + ) + navigator.navigate(FlashScreenDestination(flashIt)) + } + } } + Unit } - val currentKmi by produceState(initialValue = "") { value = getCurrentKmi() } + val currentKmi by produceState(initialValue = "") { + value = getCurrentKmi() + } val selectKmiDialog = rememberSelectKmiDialog { kmi -> kmi?.let { @@ -107,21 +124,22 @@ fun InstallScreen(navigator: DestinationsNavigator) { val onClickNext = { if (lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank()) { - // no lkm file selected and cannot get current kmi selectKmiDialog.show() } else { onInstall() } + Unit } - val selectLkmLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> - lkmSelection = LkmSelection.LkmUri(uri) - } + val selectLkmLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + lkmSelection = LkmSelection.LkmUri(uri) } } + } val onLkmUpload = { selectLkmLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { @@ -134,12 +152,14 @@ fun InstallScreen(navigator: DestinationsNavigator) { Scaffold( topBar = { TopBar( - onBack = dropUnlessResumed { navigator.popBackStack() }, + onBack = { navigator.popBackStack() }, onLkmUpload = onLkmUpload, scrollBehavior = scrollBehavior ) }, - contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + contentWindowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ) ) { innerPadding -> Column( modifier = Modifier @@ -164,11 +184,11 @@ fun InstallScreen(navigator: DestinationsNavigator) { ) ) } - Button(modifier = Modifier.fillMaxWidth(), + Button( + modifier = Modifier.fillMaxWidth(), enabled = installMethod != null, - onClick = { - onClickNext() - }) { + onClick = onClickNext + ) { Text( stringResource(id = R.string.install_next), fontSize = MaterialTheme.typography.bodyMedium.fontSize @@ -179,6 +199,166 @@ fun InstallScreen(navigator: DestinationsNavigator) { } } +private fun launchHorizonKernelFlash(context: Context, uri: Uri) { + val worker = HorizonKernelWorker(context) + worker.uri = uri + worker.setOnFlashCompleteListener { + } + worker.start() +} + +@Composable +private fun RebootDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(id = R.string.reboot_complete_title)) }, + text = { Text(stringResource(id = R.string.reboot_complete_msg)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(id = R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.no)) + } + } + ) + } +} + + +private class HorizonKernelWorker(private val context: Context) : Thread() { + var uri: Uri? = null + private lateinit var filePath: String + private lateinit var binaryPath: String + + + private var onFlashComplete: (() -> Unit)? = null + + fun setOnFlashCompleteListener(listener: () -> Unit) { + onFlashComplete = listener + } + + override fun run() { + filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}" + binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary" + + try { + cleanup() + if (!rootAvailable()) { + showError(context.getString(R.string.root_required)) + return + } + + copy() + if (!File(filePath).exists()) { + showError(context.getString(R.string.copy_failed)) + return + } + + getBinary() + patch() + flash() + + (context as? Activity)?.runOnUiThread { + onFlashComplete?.invoke() + } + } catch (e: Exception) { + showError(e.message ?: context.getString(R.string.unknown_error)) + } + } + + private fun cleanup() { + runCommand(false, "rm -rf ${context.filesDir.absolutePath}/*") + } + + 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") + } + } + + private fun patch() { + val mkbootfsPath = "${context.filesDir.absolutePath}/mkbootfs" + AssetsUtil.exportFiles(context, "mkbootfs", mkbootfsPath) + runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $mkbootfsPath \$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") + writer.write("sh $binaryPath 3 1 \"$filePath\" && touch ${context.filesDir.absolutePath}/done\nexit\n") + writer.flush() + } + + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + if (line.startsWith("ui_print")) { + showLog(line.removePrefix("ui_print")) + } + } + } + } finally { + process.destroy() + } + + if (!File("${context.filesDir.absolutePath}/done").exists()) { + throw IOException("Flash failed") + } + } + + private fun runCommand(su: Boolean, cmd: String): Int { + val process = ProcessBuilder(if (su) "su" else "sh") + .redirectErrorStream(true) + .start() + + return try { + process.outputStream.bufferedWriter().use { writer -> + writer.write("$cmd\n") + writer.write("exit\n") + writer.flush() + } + process.waitFor() + } finally { + process.destroy() + } + } + + private fun showError(message: String) { + (context as? Activity)?.runOnUiThread { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + + private fun showLog(message: String) { + (context as? Activity)?.runOnUiThread { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } +} + sealed class InstallMethod { data class SelectFile( val uri: Uri? = null, @@ -196,6 +376,12 @@ sealed class InstallMethod { get() = R.string.install_inactive_slot } + data class HorizonKernel( + val uri: Uri? = null, + @StringRes override val label: Int = R.string.horizon_kernel, + override val summary: String? = null + ) : InstallMethod() + abstract val label: Int open val summary: String? = null } @@ -205,52 +391,66 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { val rootAvailable = rootAvailable() val isAbDevice = isAbDevice() val selectFileTip = stringResource( - id = R.string.select_file_tip, if (isInitBoot()) "init_boot" else "boot" + id = R.string.select_file_tip, + if (isInitBoot()) "init_boot" else "boot" ) - val radioOptions = - mutableListOf(InstallMethod.SelectFile(summary = selectFileTip)) + + val radioOptions = mutableListOf( + InstallMethod.SelectFile(summary = selectFileTip) + ) + if (rootAvailable) { radioOptions.add(InstallMethod.DirectInstall) - if (isAbDevice) { radioOptions.add(InstallMethod.DirectInstallToInactiveSlot) } + radioOptions.add(InstallMethod.HorizonKernel(summary = "Flashing the Anykernel3 Kernel")) } var selectedOption by remember { mutableStateOf(null) } + var currentSelectingMethod by remember { mutableStateOf(null) } + val selectImageLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) { it.data?.data?.let { uri -> - val option = InstallMethod.SelectFile(uri, summary = selectFileTip) - selectedOption = option - onSelected(option) + val option = when (currentSelectingMethod) { + is InstallMethod.SelectFile -> InstallMethod.SelectFile(uri, summary = selectFileTip) + is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(uri, summary = " Flashing the Anykernel3 Kernel") + else -> null + } + option?.let { + selectedOption = it + onSelected(it) + } } } } - val confirmDialog = rememberConfirmDialog(onConfirm = { - selectedOption = InstallMethod.DirectInstallToInactiveSlot - onSelected(InstallMethod.DirectInstallToInactiveSlot) - }, onDismiss = null) + val confirmDialog = rememberConfirmDialog( + onConfirm = { + selectedOption = InstallMethod.DirectInstallToInactiveSlot + onSelected(InstallMethod.DirectInstallToInactiveSlot) + }, + onDismiss = null + ) + val dialogTitle = stringResource(id = android.R.string.dialog_alert_title) val dialogContent = stringResource(id = R.string.install_inactive_slot_warning) val onClick = { option: InstallMethod -> - + currentSelectingMethod = option when (option) { - is InstallMethod.SelectFile -> { + is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> { selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply { - type = "application/octet-stream" + type = "application/*" }) } - is InstallMethod.DirectInstall -> { selectedOption = option onSelected(option) } - is InstallMethod.DirectInstallToInactiveSlot -> { confirmDialog.showConfirm(dialogTitle, dialogContent) } @@ -266,9 +466,7 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { .fillMaxWidth() .toggleable( value = option.javaClass == selectedOption?.javaClass, - onValueChange = { - onClick(option) - }, + onValueChange = { onClick(option) }, role = Role.RadioButton, indication = LocalIndication.current, interactionSource = interactionSource @@ -276,9 +474,7 @@ private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { ) { RadioButton( selected = option.javaClass == selectedOption?.javaClass, - onClick = { - onClick(option) - }, + onClick = { onClick(option) }, interactionSource = interactionSource ) Column( @@ -311,25 +507,33 @@ fun rememberSelectKmiDialog(onSelected: (String?) -> Unit): DialogHandle { val supportedKmi by produceState(initialValue = emptyList()) { value = getSupportedKmis() } + val options = supportedKmi.map { value -> - ListOption( - titleText = value - ) + ListOption(titleText = value) } var selection by remember { mutableStateOf(null) } - ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = { - onSelected(selection) - }, onCloseRequest = { - dismiss() - }), header = Header.Default( - title = stringResource(R.string.select_kmi), - ), selection = ListSelection.Single( - showRadioButtons = true, - options = options, - ) { _, option -> - selection = option.titleText - }) + + ListDialog( + state = rememberUseCaseState( + visible = true, + onFinishedRequest = { + onSelected(selection) + }, + onCloseRequest = { + dismiss() + } + ), + header = Header.Default( + title = stringResource(R.string.select_kmi), + ), + selection = ListSelection.Single( + showRadioButtons = true, + options = options, + ) { _, option -> + selection = option.titleText + } + ) } } @@ -341,22 +545,26 @@ private fun TopBar( scrollBehavior: TopAppBarScrollBehavior? = null ) { TopAppBar( - title = { Text(stringResource(R.string.install)) }, navigationIcon = { - IconButton( - onClick = onBack - ) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } - }, actions = { + title = { Text(stringResource(R.string.install)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { IconButton(onClick = onLkmUpload) { Icon(Icons.Filled.FileUpload, contentDescription = null) } }, - windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + windowInsets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ), scrollBehavior = scrollBehavior ) } -@Composable @Preview +@Composable fun SelectInstallPreview() { InstallScreen(EmptyDestinationsNavigator) } \ No newline at end of file diff --git a/manager/app/src/main/java/shirkneko/zako/sukisu/utils/AssetsUtil.kt b/manager/app/src/main/java/shirkneko/zako/sukisu/utils/AssetsUtil.kt new file mode 100644 index 00000000..080b057d --- /dev/null +++ b/manager/app/src/main/java/shirkneko/zako/sukisu/utils/AssetsUtil.kt @@ -0,0 +1,26 @@ +package shirkneko.zako.sukisu.utils + +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) + } + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 2d911da1..b5386b5f 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -204,4 +204,14 @@ 粉色 高级灰 象牙白 + 刷入选项 + 选择要刷入的文件 + Anykernel3 刷写 + 需要 root 权限 + 文件复制失败 + 刷写完成 + 是否立即重启? + + + 重启失败 \ No newline at end of file diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 48330ace..8dadf34d 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -206,4 +206,14 @@ pink gray ivory + Brush Options + Select the file to be flashed + Anykernel3 Flush + Requires root privileges + File Copy Failure + Scrubbing complete + Whether to reboot immediately? + yes + no + Reboot Failed diff --git a/manager/gradle/libs.versions.toml b/manager/gradle/libs.versions.toml index 34aedcea..b2416367 100644 --- a/manager/gradle/libs.versions.toml +++ b/manager/gradle/libs.versions.toml @@ -21,6 +21,7 @@ compose-material = "1.7.8" compose-material3 = "1.3.1" compose-ui = "1.7.8" compose-foundation = "1.7.8" +documentfile = "1.0.1" [plugins] agp-app = { id = "com.android.application", version.ref = "agp" } @@ -79,4 +80,5 @@ sheet-compose-dialogs-input = { group = "com.maxkeppeler.sheets-compose-dialogs" markdown = { group = "io.noties.markwon", name = "core", version.ref = "markdown" } -lsposed-cxx = { module = "org.lsposed.libcxx:libcxx", version = "27.0.12077973" } \ No newline at end of file +lsposed-cxx = { module = "org.lsposed.libcxx:libcxx", version = "27.0.12077973" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } \ No newline at end of file