manager: Add module UI

This commit is contained in:
tiann
2023-01-01 16:51:14 +08:00
parent 00b4025325
commit d6dabf7b24
15 changed files with 375 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="19" />
<bytecodeTargetLevel target="11" />
</component>
</project>
</project>

View File

@@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="19" />
<option name="gradleJvm" value="Embedded JDK" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -76,6 +76,8 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.2.2")
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
implementation("com.github.topjohnwu.libsu:core:5.0.3")
ksp("io.github.raamcosta.compose-destinations:ksp:$composeDestinationsVersion")
testImplementation("junit:junit:4.13.2")

View File

@@ -11,6 +11,7 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"

View File

@@ -16,3 +16,5 @@ add_library(kernelsu
find_library(log-lib log)
target_link_libraries(kernelsu ${log-lib})
add_executable(libksu.so su.c)

View File

@@ -1,10 +1,14 @@
package me.weishu.kernelsu
import android.app.Application
import android.util.Log
import coil.Coil
import coil.ImageLoader
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ShellUtils
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import java.io.File
lateinit var ksuApp: KernelSUApplication
@@ -24,5 +28,21 @@ class KernelSUApplication : Application() {
}
.build()
)
install()
}
}
fun createRootShell(): Shell {
Shell.enableVerboseLogging = BuildConfig.DEBUG
val su = applicationInfo.nativeLibraryDir + File.separator + "libksu.so"
val builder = Shell.Builder.create()
return builder.build(su)
}
fun install() {
val shell = createRootShell()
val ksduLib = ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
val result = ShellUtils.fastCmdResult(shell, "$ksduLib install")
Log.w("KernelSU", "install ksud result: $result")
}
}

View File

@@ -1,17 +1,229 @@
package me.weishu.kernelsu.ui.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.ramcosta.composedestinations.annotation.Destination
import kotlinx.coroutines.launch
import me.weishu.kernelsu.R
import me.weishu.kernelsu.ui.util.LocalSnackbarHost
import me.weishu.kernelsu.ui.viewmodel.ModuleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Destination
@Composable
fun ModuleScreen() {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(text = "Coming Soon..")
val viewModel = viewModel<ModuleViewModel>()
val snackBarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (viewModel.moduleList.isEmpty()) {
viewModel.fetchModuleList()
}
}
Scaffold(
topBar = {
TopBar()
},
floatingActionButton = {
val moduleInstall = stringResource(id = R.string.module_install)
val selectZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != RESULT_OK) {
return@rememberLauncherForActivityResult
}
val data = it.data ?: return@rememberLauncherForActivityResult
val uri = data.data ?: return@rememberLauncherForActivityResult
scope.launch {
viewModel.installModule(uri)
}
Log.i("ModuleScreen", "select zip result: ${it.data}")
}
ExtendedFloatingActionButton(
onClick = {
// select the zip file to install
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "application/zip"
selectZipLauncher.launch(intent)
},
icon = { Icon(Icons.Filled.Add, moduleInstall) },
text = { Text(text = moduleInstall) },
)
},
) { innerPadding ->
val failedEnable = stringResource(R.string.module_failed_to_enable)
val failedDisable = stringResource(R.string.module_failed_to_disable)
val swipeState = rememberSwipeRefreshState(viewModel.isRefreshing)
// TODO: Replace SwipeRefresh with RefreshIndicator when it's ready
SwipeRefresh(
state = swipeState,
onRefresh = {
scope.launch { viewModel.fetchModuleList() }
},
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
val isEmpty = viewModel.moduleList.isEmpty()
if (isEmpty) {
swipeState.isRefreshing = false
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.module_empty))
}
} else {
LazyColumn {
items(viewModel.moduleList) { module ->
var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) }
ModuleItem(module,
isChecked,
onUninstall = {
scope.launch {
val result = viewModel.uninstallModule(module.id)
}
},
onCheckChanged = {
val success = viewModel.toggleModule(module.id, isChecked)
if (success) {
isChecked = it
} else scope.launch {
val message = if (isChecked) failedDisable else failedEnable
snackBarHost.showSnackbar(message.format(module.name))
}
})
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar() {
TopAppBar(
title = { Text(stringResource(R.string.module)) }
)
}
@Composable
private fun ModuleItem(
module: ModuleViewModel.ModuleInfo,
isChecked: Boolean,
onUninstall: (ModuleViewModel.ModuleInfo) -> Unit,
onCheckChanged: (Boolean) -> Unit
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors =
CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 0.dp)) {
Row {
Column {
Text(
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
text = module.name,
)
Row {
Text(
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
fontSize = MaterialTheme.typography.titleMedium.fontSize,
text = module.version
)
Spacer(modifier = Modifier.width(8.dp))
Text(
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
fontSize = MaterialTheme.typography.titleMedium.fontSize,
text = module.author
)
}
}
Spacer(modifier = Modifier.weight(1f))
Switch(checked = isChecked, onCheckedChange = onCheckChanged)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
fontFamily = MaterialTheme.typography.bodyMedium.fontFamily,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
text = module.description,
overflow = TextOverflow.Ellipsis,
maxLines = 4,
)
Spacer(modifier = Modifier.height(16.dp))
Divider(thickness = Dp.Hairline)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.weight(1f, true))
TextButton(
onClick = { onUninstall(module) },
) {
Text(
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
text = stringResource(R.string.uninstall),
)
}
}
}
}
}
@Preview
@Composable
fun ModuleItemPreview() {
val module = ModuleViewModel.ModuleInfo(
id = "id",
name = "name",
version = "version",
versionCode = 1,
author = "author",
description = "a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a ",
enabled = true
)
ModuleItem(module, true, {}, {})
}

View File

@@ -0,0 +1,121 @@
package me.weishu.kernelsu.ui.viewmodel
import android.net.Uri
import android.os.SystemClock
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.weishu.kernelsu.ksuApp
import org.json.JSONArray
import java.io.File
import java.text.Collator
import java.util.*
class ModuleViewModel : ViewModel() {
companion object {
private const val TAG = "ModuleViewModel"
private var modules by mutableStateOf<List<ModuleInfo>>(emptyList())
}
class ModuleInfo(
val id: String,
val name: String,
val author: String,
val version: String,
val versionCode: Int,
val description: String,
val enabled: Boolean
)
var isRefreshing by mutableStateOf(false)
private set
val moduleList by derivedStateOf {
val comparator = compareBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id)
modules.sortedWith(comparator).also {
isRefreshing = false
}
}
suspend fun fetchModuleList() {
withContext(Dispatchers.IO) {
isRefreshing = true
val start = SystemClock.elapsedRealtime()
val shell = ksuApp.createRootShell()
val ksduLib = ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
val out = shell.newJob().add("$ksduLib module list").to(ArrayList(), null).exec().out
val result = out.joinToString("\n")
Log.i(TAG, "result: $result")
val array = JSONArray(result)
modules = (0 until array.length())
.asSequence()
.map { array.getJSONObject(it) }
.map { obj ->
ModuleInfo(
obj.getString("id"),
obj.getString("name"),
obj.getString("author"),
obj.getString("version"),
obj.getInt("versionCode"),
obj.getString("description"),
obj.getBoolean("enabled")
)
}.toList()
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
}
}
private fun execKsud(args: String): Boolean {
val shell = ksuApp.createRootShell()
val ksduLib = ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
return ShellUtils.fastCmdResult(shell, "$ksduLib $args")
}
fun toggleModule(id: String, enable: Boolean): Boolean {
val cmd = if (enable) {
"module enable $id"
} else {
"module disable $id"
}
val result = execKsud(cmd)
Log.i(TAG, "toggle module $id result: $result")
return result
}
fun uninstallModule(id: String) : Boolean {
val cmd = "module uninstall $id"
val result = execKsud(cmd)
Log.i(TAG, "uninstall module $id result: $result")
return result
}
fun installModule(uri: Uri) : Boolean {
val resolver = ksuApp.contentResolver
with(resolver.openInputStream(uri)) {
val file = File(ksuApp.cacheDir, "module.zip")
file.outputStream().use { output ->
this?.copyTo(output)
}
val cmd = "module install ${file.absolutePath}"
val result = execKsud(cmd)
Log.i(TAG, "install module $uri result: $result")
file.delete()
return result
}
}
}

View File

@@ -13,6 +13,11 @@
<string name="superuser">Superuser</string>
<string name="superuser_failed_to_grant_root">Failed to grant root for %d</string>
<string name="module_failed_to_enable">Failed to enable module: %s</string>
<string name="module_failed_to_disable">Failed to disable module: %s</string>
<string name="module_empty">No module installed</string>
<string name="module">Module</string>
<string name="uninstall">Uninstall</string>
<string name="module_install">Install</string>
</resources>

View File

@@ -21,6 +21,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}

View File

@@ -1,2 +0,0 @@
/obj
/libs

View File

@@ -1,7 +0,0 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := su
LOCAL_SRC_FILES := su.c
include $(BUILD_EXECUTABLE)

View File

@@ -1,3 +0,0 @@
APP_ABI := arm64-v8a x86_64
APP_PLATFORM := android-24
APP_STL := none