diff --git a/.github/workflows/build-lkm.yml b/.github/workflows/build-lkm.yml index 30976e2cba78..f9fe6d7cbf2e 100644 --- a/.github/workflows/build-lkm.yml +++ b/.github/workflows/build-lkm.yml @@ -4,11 +4,13 @@ on: branches: ["main", "ci", "checkci"] paths: - ".github/workflows/gki-kernel.yml" + - ".github/workflows/build-lkm.yml" - "kernel/**" pull_request: branches: ["main"] paths: - ".github/workflows/gki-kernel.yml" + - ".github/workflows/build-lkm.yml" - "kernel/**" workflow_call: jobs: @@ -29,8 +31,8 @@ jobs: sub_level: 110 os_patch_level: 2023-09 - version: "android14-6.1" - sub_level: 68 - os_patch_level: 2024-02 + sub_level: 43 + os_patch_level: 2023-11 uses: ./.github/workflows/gki-kernel.yml with: version: ${{ matrix.version }} diff --git a/manager/app/src/main/java/me/weishu/kernelsu/Kernels.kt b/manager/app/src/main/java/me/weishu/kernelsu/Kernels.kt index 860e46fcb149..dadbd699e1ae 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/Kernels.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/Kernels.kt @@ -37,6 +37,23 @@ fun parseKernelVersion(version: String): KernelVersion { } } +fun parseKMI(input: String): String? { + val regex = Regex("(.* )?(\\d+\\.\\d+)(\\S+)?(android\\d+)(.*)") + val result = regex.find(input) + + return result?.let { + val androidVersion = it.groups[4]?.value ?: "" + val kernelVersion = it.groups[2]?.value ?: "" + "$androidVersion-$kernelVersion" + } +} + +fun getKMI(): String? { + Os.uname().release.let { + return parseKMI(it) + } +} + fun getKernelVersion(): KernelVersion { Os.uname().release.let { return parseKernelVersion(it) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Flash.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Flash.kt new file mode 100644 index 000000000000..3d18901e3ebf --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Flash.kt @@ -0,0 +1,189 @@ +package me.weishu.kernelsu.ui.screen + +import android.net.Uri +import android.os.Environment +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.component.KeyEventBlocker +import me.weishu.kernelsu.ui.util.LocalSnackbarHost +import me.weishu.kernelsu.ui.util.installBoot +import me.weishu.kernelsu.ui.util.installModule +import me.weishu.kernelsu.ui.util.reboot +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * @author weishu + * @date 2023/1/1. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +@Destination +fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) { + + var text by rememberSaveable { mutableStateOf("") } + val logContent = rememberSaveable { StringBuilder() } + var showFloatAction by rememberSaveable { mutableStateOf(false) } + + val snackBarHost = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + LaunchedEffect(Unit) { + if (text.isNotEmpty()) { + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + flashIt(flashIt, onFinish = { showReboot -> + if (showReboot) { + showFloatAction = true + } + }, onStdout = { + text += "$it\n" + logContent.append(it).append("\n") + }, onStderr = { + logContent.append(it).append("\n") + }); + } + } + + Scaffold( + topBar = { + TopBar( + onBack = { + navigator.popBackStack() + }, + 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_install_log_${date}.log" + ) + file.writeText(logContent.toString()) + snackBarHost.showSnackbar("Log saved to ${file.absolutePath}") + } + } + ) + }, + floatingActionButton = { + if (showFloatAction) { + val reboot = stringResource(id = R.string.reboot) + ExtendedFloatingActionButton( + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + reboot() + } + } + }, + icon = { Icon(Icons.Filled.Refresh, reboot) }, + text = { Text(text = reboot) }, + ) + } + + } + ) { innerPadding -> + KeyEventBlocker { + it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + Column( + modifier = Modifier + .fillMaxSize(1f) + .padding(innerPadding) + .verticalScroll(scrollState), + ) { + LaunchedEffect(text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + Text( + modifier = Modifier.padding(8.dp), + text = text, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + fontFamily = FontFamily.Monospace, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight, + ) + } + } +} + +@Parcelize +sealed class FlashIt : Parcelable { + data class FlashBoot(val bootUri: Uri? = null, val koUri: Uri, val ota: Boolean) : FlashIt() + + data class FlashModule(val uri: Uri) : FlashIt() +} + +fun flashIt( + flashIt: FlashIt, onFinish: (Boolean) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +) { + when (flashIt) { + is FlashIt.FlashBoot -> installBoot( + flashIt.bootUri, + flashIt.koUri, + flashIt.ota, + onFinish, + onStdout, + onStderr + ) + + is FlashIt.FlashModule -> installModule(flashIt.uri, onFinish, onStdout, onStderr) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) { + TopAppBar( + title = { Text(stringResource(R.string.install)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.Filled.ArrowBack, contentDescription = null) } + }, + actions = { + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = "Localized description" + ) + } + } + ) +} + +@Preview +@Composable +fun InstallPreview() { +// InstallScreen(DestinationsNavigator(), uri = Uri.EMPTY) +} \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Home.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Home.kt index 2bbe80c34b33..1bcbad2ce94a 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Home.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Home.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.withContext import me.weishu.kernelsu.* import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.rememberConfirmDialog +import me.weishu.kernelsu.ui.screen.destinations.InstallScreenDestination import me.weishu.kernelsu.ui.screen.destinations.SettingScreenDestination import me.weishu.kernelsu.ui.util.* @@ -60,7 +61,9 @@ fun HomeScreen(navigator: DestinationsNavigator) { } val ksuVersion = if (isManager) Natives.version else null - StatusCard(kernelVersion, ksuVersion) + StatusCard(kernelVersion, ksuVersion) { + navigator.navigate(InstallScreenDestination) + } if (isManager && Natives.requireNewKernel()) { WarningCard( stringResource(id = R.string.require_kernel_version).format( @@ -68,7 +71,7 @@ fun HomeScreen(navigator: DestinationsNavigator) { ) ) } - if (!rootAvailable()) { + if (ksuVersion != null && !rootAvailable()) { WarningCard( stringResource(id = R.string.grant_root_failed) ) @@ -174,7 +177,7 @@ private fun TopBar(onSettingsClick: () -> Unit) { } @Composable -private fun StatusCard(kernelVersion: KernelVersion, ksuVersion: Int?) { +private fun StatusCard(kernelVersion: KernelVersion, ksuVersion: Int?, onClickInstall: () -> Unit = {}) { ElevatedCard( colors = CardDefaults.elevatedCardColors(containerColor = run { if (ksuVersion != null) MaterialTheme.colorScheme.secondaryContainer @@ -185,8 +188,8 @@ private fun StatusCard(kernelVersion: KernelVersion, ksuVersion: Int?) { Row(modifier = Modifier .fillMaxWidth() .clickable { - if (kernelVersion.isGKI() && ksuVersion == null) { - uriHandler.openUri("https://kernelsu.org/guide/installation.html") + if (kernelVersion.isGKI()) { + onClickInstall() } } .padding(24.dp), verticalAlignment = Alignment.CenterVertically) { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Install.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Install.kt index fdcbac883d1a..e196caddd45d 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Install.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Install.kt @@ -1,140 +1,262 @@ package me.weishu.kernelsu.ui.screen +import android.app.Activity +import android.content.Intent import android.net.Uri -import android.os.Environment +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import me.weishu.kernelsu.R -import me.weishu.kernelsu.ui.component.KeyEventBlocker -import me.weishu.kernelsu.ui.util.LocalSnackbarHost -import me.weishu.kernelsu.ui.util.installModule -import me.weishu.kernelsu.ui.util.reboot -import java.io.File -import java.text.SimpleDateFormat -import java.util.* +import me.weishu.kernelsu.ui.component.rememberConfirmDialog +import me.weishu.kernelsu.ui.component.rememberLoadingDialog +import me.weishu.kernelsu.ui.screen.destinations.FlashScreenDestination +import me.weishu.kernelsu.ui.util.DownloadListener +import me.weishu.kernelsu.ui.util.download +import me.weishu.kernelsu.ui.util.getLKMUrl +import me.weishu.kernelsu.ui.util.isAbDevice +import me.weishu.kernelsu.ui.util.rootAvailable /** * @author weishu - * @date 2023/1/1. + * @date 2024/3/12. */ -@OptIn(ExperimentalComposeUiApi::class) -@Composable @Destination -fun InstallScreen(navigator: DestinationsNavigator, uri: Uri) { +@Composable +fun InstallScreen(navigator: DestinationsNavigator) { + val scope = rememberCoroutineScope() + val loadingDialog = rememberLoadingDialog() + val context = LocalContext.current + var installMethod by remember { + mutableStateOf(null) + } - var text by rememberSaveable { mutableStateOf("") } - val logContent = rememberSaveable { StringBuilder() } - var showFloatAction by rememberSaveable { mutableStateOf(false) } + val onFileDownloaded = { uri: Uri -> - val snackBarHost = LocalSnackbarHost.current - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() + installMethod?.let { + scope.launch(Dispatchers.Main) { + when (it) { + InstallMethod.DirectInstall -> { + navigator.navigate( + FlashScreenDestination( + FlashIt.FlashBoot( + null, + uri, + false + ) + ) + ) + } - LaunchedEffect(Unit) { - if (text.isNotEmpty()) { - return@LaunchedEffect - } - withContext(Dispatchers.IO) { - installModule(uri, onFinish = { success -> - if (success) { - showFloatAction = true + InstallMethod.DirectInstallToInactiveSlot -> { + navigator.navigate( + FlashScreenDestination( + FlashIt.FlashBoot( + null, + uri, + true + ) + ) + ) + } + + is InstallMethod.SelectFile -> { + navigator.navigate( + FlashScreenDestination( + FlashIt.FlashBoot( + it.uri, + uri, + false + ) + ) + ) + } } - }, onStdout = { - text += "$it\n" - logContent.append(it).append("\n") - }, onStderr = { - logContent.append(it).append("\n") - }); + } } } - Scaffold( - topBar = { - TopBar( - onBack = { - navigator.popBackStack() - }, - 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_install_log_${date}.log" - ) - file.writeText(logContent.toString()) - snackBarHost.showSnackbar("Log saved to ${file.absolutePath}") - } + Scaffold(topBar = { + TopBar { + navigator.popBackStack() + } + }) { + Column(modifier = Modifier.padding(it)) { + SelectInstallMethod { method -> + installMethod = method + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + + DownloadListener(context = context) { uri -> + onFileDownloaded(uri) + loadingDialog.hide() } - ) - }, - floatingActionButton = { - if (showFloatAction) { - val reboot = stringResource(id = R.string.reboot) - ExtendedFloatingActionButton( + Button( + modifier = Modifier.fillMaxWidth(), + enabled = installMethod != null, onClick = { - scope.launch { - withContext(Dispatchers.IO) { - reboot() + loadingDialog.showLoading() + scope.launch(Dispatchers.IO) { + getLKMUrl().onFailure { throwable -> + loadingDialog.hide() + scope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "Failed to fetch LKM url: ${throwable.message}", + Toast.LENGTH_SHORT + ).show() + } + }.onSuccess { result -> + loadingDialog.hide() + + download( + context = context, + url = result.second, + fileName = result.first, + description = "Downloading ${result.first}", + onDownloaded = { uri -> + onFileDownloaded(uri) + loadingDialog.hide() + }, + onDownloading = {} + ) } } - }, - icon = { Icon(Icons.Filled.Refresh, reboot) }, - text = { Text(text = reboot) }, - ) + }) { + Text("Next", fontSize = MaterialTheme.typography.bodyMedium.fontSize) + } } + } + } +} + +sealed class InstallMethod { + data class SelectFile(val uri: Uri? = null, override val label: Int = R.string.select_file) : + InstallMethod() + object DirectInstall : InstallMethod() { + override val label: Int + get() = R.string.direct_install + } + + object DirectInstallToInactiveSlot : InstallMethod() { + override val label: Int + get() = R.string.install_inactive_slot + } + + abstract val label: Int +} + +@Composable +private fun SelectInstallMethod(onSelected: (InstallMethod) -> Unit = {}) { + val rootAvailable = rootAvailable() + val isAbDevice = isAbDevice() + val radioOptions = mutableListOf(InstallMethod.SelectFile()) + if (rootAvailable) { + radioOptions.add(InstallMethod.DirectInstall) + + if (isAbDevice) { + radioOptions.add(InstallMethod.DirectInstallToInactiveSlot) } - ) { innerPadding -> - KeyEventBlocker { - it.key == Key.VolumeDown || it.key == Key.VolumeUp + } + + var selectedOption 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) + selectedOption = option + onSelected(option) + } + } + } + + 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 -> + + when (option) { + is InstallMethod.SelectFile -> { + selectImageLauncher.launch( + Intent(Intent.ACTION_GET_CONTENT).apply { + type = "application/octet-stream" + } + ) + } + + is InstallMethod.DirectInstall -> { + selectedOption = option + onSelected(option) + } + is InstallMethod.DirectInstallToInactiveSlot -> { + confirmDialog.showConfirm(dialogTitle, dialogContent) + } } - Column( - modifier = Modifier - .fillMaxSize(1f) - .padding(innerPadding) - .verticalScroll(scrollState), - ) { - LaunchedEffect(text) { - scrollState.animateScrollTo(scrollState.maxValue) + } + + Column { + radioOptions.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick(option) + }) { + RadioButton(selected = option.javaClass == selectedOption?.javaClass, onClick = { + onClick(option) + }) + Text(text = stringResource(id = option.label)) } - Text( - modifier = Modifier.padding(8.dp), - text = text, - fontSize = MaterialTheme.typography.bodySmall.fontSize, - fontFamily = FontFamily.Monospace, - lineHeight = MaterialTheme.typography.bodySmall.lineHeight, - ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) { +private fun TopBar(onBack: () -> Unit = {}) { TopAppBar( title = { Text(stringResource(R.string.install)) }, navigationIcon = { @@ -142,19 +264,11 @@ private fun TopBar(onBack: () -> Unit = {}, onSave: () -> Unit = {}) { onClick = onBack ) { Icon(Icons.Filled.ArrowBack, contentDescription = null) } }, - actions = { - IconButton(onClick = onSave) { - Icon( - imageVector = Icons.Filled.Save, - contentDescription = "Localized description" - ) - } - } ) } -@Preview @Composable -fun InstallPreview() { -// InstallScreen(DestinationsNavigator(), uri = Uri.EMPTY) +@Preview +fun SelectInstall_Preview() { +// InstallScreen(DestinationsNavigator()) } \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt index 08f8ffbb8be6..501b08746cde 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Module.kt @@ -43,7 +43,7 @@ import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.ConfirmResult import me.weishu.kernelsu.ui.component.rememberConfirmDialog import me.weishu.kernelsu.ui.component.rememberLoadingDialog -import me.weishu.kernelsu.ui.screen.destinations.InstallScreenDestination +import me.weishu.kernelsu.ui.screen.destinations.FlashScreenDestination import me.weishu.kernelsu.ui.screen.destinations.WebScreenDestination import me.weishu.kernelsu.ui.util.* import me.weishu.kernelsu.ui.viewmodel.ModuleViewModel @@ -81,7 +81,7 @@ fun ModuleScreen(navigator: DestinationsNavigator) { val data = it.data ?: return@rememberLauncherForActivityResult val uri = data.data ?: return@rememberLauncherForActivityResult - navigator.navigate(InstallScreenDestination(uri)) + navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(uri))) viewModel.markNeedRefresh() @@ -123,7 +123,7 @@ fun ModuleScreen(navigator: DestinationsNavigator) { .fillMaxSize(), onInstallModule = { - navigator.navigate(InstallScreenDestination(it)) + navigator.navigate(FlashScreenDestination(FlashIt.FlashModule(it))) }, onClickModule = { id, name, hasWebUi -> if (hasWebUi) { navigator.navigate(WebScreenDestination(id, name)) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt index cfe98fae5827..0447176cc7d2 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt @@ -10,6 +10,7 @@ import android.net.Uri import android.os.Environment import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import me.weishu.kernelsu.getKMI /** * @author weishu @@ -94,6 +95,38 @@ fun checkNewVersion(): Triple { } return defaultValue } +fun getLKMUrl(): Result> { + val url = "https://api.github.com/repos/tiann/KernelSU/releases/latest" + + val kmi = getKMI() ?: return Result.failure(RuntimeException("Get KMI failed")) + runCatching { + okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build()).execute() + .use { response -> + val body = response.body?.string() ?: return Result.failure(RuntimeException("request body failed")) + if (!response.isSuccessful) { + return Result.failure(RuntimeException("Request failed, code: ${response.code}, message: $body")) + } + val json = org.json.JSONObject(body) + + val assets = json.getJSONArray("assets") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + if (!name.endsWith(".ko")) { + continue + } + + if (name.contains(kmi)) { + return Result.success(Pair(name, asset.getString("browser_download_url"))) + } + } + } + }.onFailure { + return Result.failure(it) + } + + return Result.failure(RuntimeException("Cannot find LKM for $kmi")) +} @Composable fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) { diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt index fcdeab6e4df2..912f54c104ef 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/KsuCli.kt @@ -1,6 +1,7 @@ package me.weishu.kernelsu.ui.util import android.net.Uri +import android.os.Environment import android.os.SystemClock import android.util.Log import com.topjohnwu.superuser.CallbackList @@ -138,6 +139,84 @@ fun installModule( } } +fun installBoot( + bootUri: Uri?, + lkmUri: Uri, + ota: Boolean, + onFinish: (Boolean) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +): Boolean { + val resolver = ksuApp.contentResolver + + with(resolver.openInputStream(lkmUri)) { + val lkmFile = File(ksuApp.cacheDir, "kernelsu.ko") + lkmFile.outputStream().use { output -> + this?.copyTo(output) + } + + if (!lkmFile.exists()) { + onStdout("- kernelsu.ko not found") + onFinish(false) + return false + } + + val bootFile = bootUri?.let { uri -> + with(resolver.openInputStream(uri)) { + val bootFile = File(ksuApp.cacheDir, "boot.img") + bootFile.outputStream().use { output -> + this?.copyTo(output) + } + + bootFile + } + } + + val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so") + var cmd = "boot-patch -m ${lkmFile.absolutePath} --magiskboot ${magiskboot.absolutePath}" + + cmd += if (bootFile == null) { + // no boot.img, use -f to force install + " -f" + } else { + " -b ${bootFile.absolutePath}" + } + + if (ota) { + cmd += " -u" + } + + // output dir + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + cmd += " -o $downloadsDir" + + val shell = createRootShell() + + val stdoutCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStdout(s ?: "") + } + } + + val stderrCallback: CallbackList = object : CallbackList() { + override fun onAddElement(s: String?) { + onStderr(s ?: "") + } + } + + val result = + shell.newJob().add("${getKsuDaemonPath()} $cmd").to(stdoutCallback, stderrCallback) + .exec() + Log.i("KernelSU", "install boot $lkmUri result: $result") + + lkmFile.delete() + bootFile?.delete() + + onFinish(bootUri != null && result.isSuccess) + return result.isSuccess + } +} + fun reboot(reason: String = "") { val shell = getRootShell() if (reason == "recovery") { @@ -152,6 +231,11 @@ fun rootAvailable(): Boolean { return shell.isRoot } +fun isAbDevice(): Boolean { + val shell = getRootShell() + return ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim().toBoolean() +} + fun overlayFsAvailable(): Boolean { val shell = getRootShell() // check /proc/filesystems diff --git a/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so b/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so new file mode 100644 index 000000000000..451ca81c42d8 Binary files /dev/null and b/manager/app/src/main/jniLibs/arm64-v8a/libmagiskboot.so differ 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 b9a8b6122a63..86a31f79d7d4 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -108,4 +108,8 @@ 打开 启用 WebView 调试 可用于调试 WebUI ,请仅在需要时启用。 + 直接安装(推荐) + 选择一个文件 + 安装到未使用的槽位(OTA 后) + 将在重启后强制切换到另一个槽位!\n注意只能在 OTA 更新完成后的重启之前使用。\n确认? \ 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 b2429557f5f3..8162b9cfae58 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -110,4 +110,8 @@ Open Enable WebView Debugging Can be used to debug WebUI, please enable only when needed. + Direct Install (Recommended) + Select a File + Install to Inactive Slot (After OTA) + Your device will be **FORCED** to boot to the current inactive slot after a reboot!\nOnly use this option after OTA is done.\nContinue? diff --git a/userspace/ksud/bin/aarch64/bootctl b/userspace/ksud/bin/aarch64/bootctl new file mode 100644 index 000000000000..cf5c61368467 Binary files /dev/null and b/userspace/ksud/bin/aarch64/bootctl differ diff --git a/userspace/ksud/bin/aarch64/ksuinit b/userspace/ksud/bin/aarch64/ksuinit new file mode 100755 index 000000000000..f9e66bbdc175 Binary files /dev/null and b/userspace/ksud/bin/aarch64/ksuinit differ diff --git a/userspace/ksud/bin/x86_64/ksuinit b/userspace/ksud/bin/x86_64/ksuinit new file mode 100755 index 000000000000..163a80be52d4 Binary files /dev/null and b/userspace/ksud/bin/x86_64/ksuinit differ diff --git a/userspace/ksud/src/assets.rs b/userspace/ksud/src/assets.rs index 2be03cf37d71..fca484e393ce 100644 --- a/userspace/ksud/src/assets.rs +++ b/userspace/ksud/src/assets.rs @@ -1,11 +1,13 @@ use anyhow::Result; use const_format::concatcp; use rust_embed::RustEmbed; +use std::path::Path; use crate::{defs::BINARY_DIR, utils}; pub const RESETPROP_PATH: &str = concatcp!(BINARY_DIR, "resetprop"); pub const BUSYBOX_PATH: &str = concatcp!(BINARY_DIR, "busybox"); +pub const BOOTCTL_PATH: &str = concatcp!(BINARY_DIR, "bootctl"); #[cfg(target_arch = "aarch64")] #[derive(RustEmbed)] @@ -19,11 +21,17 @@ struct Asset; pub fn ensure_binaries(ignore_if_exist: bool) -> Result<()> { for file in Asset::iter() { - utils::ensure_binary( - format!("{BINARY_DIR}{file}"), - &Asset::get(&file).unwrap().data, - ignore_if_exist, - )? + if file == "ksuinit" { + continue; + } + let asset = Asset::get(&file).ok_or(anyhow::anyhow!("asset not found: {}", file))?; + utils::ensure_binary(format!("{BINARY_DIR}{file}"), &asset.data, ignore_if_exist)? } Ok(()) } + +pub fn copy_assets_to_file(name: &str, dst: impl AsRef) -> Result<()> { + let asset = Asset::get(name).ok_or(anyhow::anyhow!("asset not found: {}", name))?; + std::fs::write(dst, asset.data)?; + Ok(()) +} diff --git a/userspace/ksud/src/boot_patch.rs b/userspace/ksud/src/boot_patch.rs index 24fa6ef24e48..c27e64570550 100644 --- a/userspace/ksud/src/boot_patch.rs +++ b/userspace/ksud/src/boot_patch.rs @@ -1,27 +1,52 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; use anyhow::Context; use anyhow::Result; -use is_executable::IsExecutable; use std::path::Path; use std::path::PathBuf; use std::process::Command; use std::process::Stdio; +use which::which; -use crate::utils; +use crate::{assets, utils}; #[cfg(unix)] fn ensure_gki_kernel() -> Result<()> { - let version = - procfs::sys::kernel::Version::current().with_context(|| "get kernel version failed")?; - let is_gki = version.major == 5 && version.minor >= 10 || version.major > 5; + let version = get_kernel_version()?; + let is_gki = version.0 == 5 && version.1 >= 10 || version.2 > 5; ensure!(is_gki, "only support GKI kernel"); Ok(()) } +#[cfg(unix)] +pub fn get_kernel_version() -> Result<(i32, i32, i32)> { + use regex::Regex; + let uname = rustix::system::uname(); + let version = uname.release().to_string_lossy(); + let re = Regex::new(r"(\d+)\.(\d+)\.(\d+)")?; + if let Some(captures) = re.captures(&version) { + let major = captures + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Major version parse error"))?; + let minor = captures + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Minor version parse error"))?; + let patch = captures + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| anyhow!("Patch version parse error"))?; + Ok((major, minor, patch)) + } else { + Err(anyhow!("Invalid kernel version string")) + } +} + fn do_cpio_cmd(magiskboot: &Path, workding_dir: &Path, cmd: &str) -> Result<()> { let status = Command::new(magiskboot) .current_dir(workding_dir) @@ -63,6 +88,26 @@ pub fn patch( out: Option, magiskboot_path: Option, ) -> Result<()> { + let result = do_patch(image, kernel, kmod, init, ota, flash, out, magiskboot_path); + if let Err(ref e) = result { + println!("-Install Error: {e}"); + } + result +} + +#[allow(clippy::too_many_arguments)] +fn do_patch( + image: Option, + kernel: Option, + kmod: Option, + init: Option, + ota: bool, + flash: bool, + out: Option, + magiskboot_path: Option, +) -> Result<()> { + println!(include_str!("banner")); + if image.is_none() { #[cfg(unix)] ensure_gki_kernel()?; @@ -76,19 +121,17 @@ pub fn patch( "init and module must not be specified." ); } else { - ensure!( - init.is_some() && kmod.is_some(), - "init and module must be specified" - ); + ensure!(kmod.is_some(), "module must be specified"); } - let workding_dir = tempdir::TempDir::new("KernelSU")?; + let workding_dir = + tempdir::TempDir::new("KernelSU").with_context(|| "create temp dir failed")?; let bootimage; let mut bootdevice = None; - if let Some(image) = image { + if let Some(ref image) = image { ensure!(image.exists(), "boot image not found"); bootimage = std::fs::canonicalize(image)?; } else { @@ -111,7 +154,7 @@ pub fn patch( format!("/dev/block/by-name/boot{slot_suffix}") }; - println!("bootdevice: {boot_partition}"); + println!("- Bootdevice: {boot_partition}"); let tmp_boot_path = workding_dir.path().join("boot.img"); dd(&boot_partition, &tmp_boot_path)?; @@ -122,37 +165,56 @@ pub fn patch( bootdevice = Some(boot_partition); }; - println!("boot image: {bootimage:?}"); - - let magiskboot = magiskboot_path - .map(std::fs::canonicalize) - .transpose()? - .unwrap_or_else(|| "magiskboot".into()); - - if !magiskboot.is_executable() { - #[cfg(unix)] - std::fs::set_permissions(&magiskboot, std::fs::Permissions::from_mode(0o755)) - .with_context(|| "set magiskboot executable failed".to_string())?; - } + // try extract magiskboot/bootctl + let _ = assets::ensure_binaries(false); - ensure!(magiskboot.exists(), "magiskboot not found"); + // extract magiskboot + let magiskboot = { + if which("magiskboot").is_ok() { + let _ = assets::ensure_binaries(true); + "magiskboot".into() + } else { + // magiskboot is not in $PATH, use builtin or specified one + let magiskboot = if let Some(magiskboot_path) = magiskboot_path { + std::fs::canonicalize(magiskboot_path)? + } else { + let magiskboot_path = workding_dir.path().join("magiskboot"); + assets::copy_assets_to_file("magiskboot", &magiskboot_path) + .with_context(|| "copy magiskboot failed")?; + magiskboot_path + }; + ensure!(magiskboot.exists(), "{magiskboot:?} is not exist"); + #[cfg(unix)] + let _ = std::fs::set_permissions(&magiskboot, std::fs::Permissions::from_mode(0o755)); + magiskboot + } + }; if let Some(kernel) = kernel { std::fs::copy(kernel, workding_dir.path().join("kernel")) .with_context(|| "copy kernel from failed".to_string())?; } - if let (Some(kmod), Some(init)) = (kmod, init) { + if let Some(kmod) = kmod { + println!("- Preparing assets"); + std::fs::copy(kmod, workding_dir.path().join("kernelsu.ko")) .with_context(|| "copy kernel module failed".to_string())?; - std::fs::copy(init, workding_dir.path().join("init")) - .with_context(|| "copy init failed".to_string())?; + let init_file = workding_dir.path().join("init"); + if let Some(init) = init { + std::fs::copy(init, workding_dir.path().join("init")) + .with_context(|| "copy init failed".to_string())?; + } else { + crate::assets::copy_assets_to_file("ksuinit", init_file) + .with_context(|| "copy ksuinit failed")?; + } // magiskboot unpack boot.img // magiskboot cpio ramdisk.cpio 'cp init init.real' // magiskboot cpio ramdisk.cpio 'add 0755 ksuinit init' // magiskboot cpio ramdisk.cpio 'add 0755 kernelsu.ko' + println!("- Unpacking boot image"); let status = Command::new(&magiskboot) .current_dir(workding_dir.path()) .stdout(Stdio::null()) @@ -162,6 +224,7 @@ pub fn patch( .status()?; ensure!(status.success(), "magiskboot unpack failed"); + println!("- Adding KernelSU LKM"); let is_kernelsu_patched = do_cpio_cmd(&magiskboot, workding_dir.path(), "exists kernelsu.ko").is_ok(); if !is_kernelsu_patched { @@ -180,6 +243,7 @@ pub fn patch( )?; } + println!("- Repacking boot image"); // magiskboot repack boot.img let status = Command::new(&magiskboot) .current_dir(workding_dir.path()) @@ -189,18 +253,25 @@ pub fn patch( .arg(bootimage.display().to_string()) .status()?; ensure!(status.success(), "magiskboot repack failed"); - - let out = out.unwrap_or(std::env::current_dir()?); - - let now = chrono::Utc::now(); - let output_image = out.join(format!( - "kernelsu_patched_boot_{}.img", - now.format("%Y%m%d_%H%M%S") - )); - std::fs::copy(workding_dir.path().join("new-boot.img"), &output_image) - .with_context(|| "copy out new boot failed".to_string())?; + let new_boot = workding_dir.path().join("new-boot.img"); + + if image.is_some() { + // if image is specified, write to output file + let output_dir = out.unwrap_or(std::env::current_dir()?); + let now = chrono::Utc::now(); + let output_image = + output_dir.join(format!("kernelsu_boot_{}.img", now.format("%Y%m%d_%H%M%S"))); + + if std::fs::rename(&new_boot, &output_image).is_err() { + std::fs::copy(&new_boot, &output_image) + .with_context(|| "copy out new boot failed".to_string())?; + } + println!("- Output file is written to"); + println!("- {}", output_image.display().to_string().trim_matches('"')); + } if flash { + println!("- Flashing new boot image"); let Some(bootdevice) = bootdevice else { bail!("boot device not found") }; @@ -210,7 +281,52 @@ pub fn patch( .status()?; ensure!(status.success(), "set boot device rw failed"); - dd(&output_image, &bootdevice).with_context(|| "flash boot failed")?; + dd(&new_boot, &bootdevice).with_context(|| "flash boot failed")?; + + if ota { + post_ota()?; + } } + + println!("- Done!"); + Ok(()) +} + +fn post_ota() -> Result<()> { + use crate::defs::ADB_DIR; + use assets::BOOTCTL_PATH; + let status = Command::new(BOOTCTL_PATH).arg("hal-info").status()?; + if !status.success() { + return Ok(()); + } + + let current_slot = Command::new(BOOTCTL_PATH) + .arg("get-current-slot") + .output()? + .stdout; + let current_slot = String::from_utf8(current_slot)?; + let current_slot = current_slot.trim(); + let target_slot = if current_slot == "0" { 1 } else { 0 }; + + Command::new(BOOTCTL_PATH) + .arg(format!("set-active-boot-slot {target_slot}")) + .status()?; + + let post_ota_sh = std::path::Path::new(ADB_DIR) + .join("post-fs-data.d") + .join("post_ota.sh"); + + let sh_content = format!( + r###" +{BOOTCTL_PATH} mark-boot-successful +rm -f {BOOTCTL_PATH} +rm -f /data/adb/post-fs-data.d/post_ota.sh +"### + ); + + std::fs::write(&post_ota_sh, sh_content)?; + #[cfg(unix)] + std::fs::set_permissions(post_ota_sh, std::fs::Permissions::from_mode(0o755))?; + Ok(()) } diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index ffca157a2142..1d272319ce7a 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -7,7 +7,7 @@ use android_logger::Config; #[cfg(target_os = "android")] use log::LevelFilter; -use crate::{apk_sign, debug, defs, init_event, ksucalls, module, utils}; +use crate::{apk_sign, assets, debug, defs, init_event, ksucalls, module, utils}; /// KernelSU userspace cli #[derive(Parser, Debug)] @@ -60,10 +60,10 @@ enum Commands { kernel: Option, /// LKM module path to replace - #[arg(short, long, requires("init"))] + #[arg(short, long)] module: Option, - /// init to be replaced, if use LKM, this must be specified + /// init to be replaced #[arg(short, long, requires("module"))] init: Option, @@ -304,7 +304,7 @@ pub fn run() -> Result<()> { utils::copy_sparse_file(src, dst, punch_hole)?; Ok(()) } - Debug::Test => todo!(), + Debug::Test => assets::ensure_binaries(false), }, Commands::BootPatch {