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