From 9b294682b0ede778dcb00b73525c995d40fd9010 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 21 Oct 2023 13:19:59 +0800 Subject: [PATCH] manager: support App Profile template --- .../main/java/me/weishu/kernelsu/Natives.kt | 9 +- .../ui/component/profile/RootProfileConfig.kt | 12 +- .../ui/component/profile/TemplateConfig.kt | 104 ++++++ .../weishu/kernelsu/ui/screen/AppProfile.kt | 70 +--- .../me/weishu/kernelsu/ui/screen/Settings.kt | 12 + .../me/weishu/kernelsu/ui/screen/Template.kt | 148 ++++++++ .../kernelsu/ui/screen/TemplateEditor.kt | 345 ++++++++++++++++++ .../java/me/weishu/kernelsu/ui/util/KsuCli.kt | 49 ++- .../ui/viewmodel/TemplateViewModel.kt | 208 +++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 13 + manager/app/src/main/res/values/strings.xml | 13 + userspace/ksud/src/cli.rs | 29 +- userspace/ksud/src/profile.rs | 20 +- 13 files changed, 946 insertions(+), 86 deletions(-) create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/TemplateConfig.kt create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt create mode 100644 manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt diff --git a/manager/app/src/main/java/me/weishu/kernelsu/Natives.kt b/manager/app/src/main/java/me/weishu/kernelsu/Natives.kt index 58624da7..e672bf18 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/Natives.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/Natives.kt @@ -45,7 +45,6 @@ object Natives { external fun setAppProfile(profile: Profile?): Boolean private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" - private const val ROOT_DEFAULT_PROFILE_KEY = "#" private const val NOBODY_UID = 9999 fun setDefaultUmountModules(umountModules: Boolean): Boolean { @@ -90,16 +89,16 @@ object Natives { val groups: List = mutableListOf(), val capabilities: List = mutableListOf(), val context: String = "u:r:su:s0", - val namespace: Int = Namespace.Inherited.ordinal, + val namespace: Int = Namespace.INHERITED.ordinal, val nonRootUseDefault: Boolean = true, val umountModules: Boolean = true, var rules: String = "", // this field is save in ksud!! ) : Parcelable { enum class Namespace { - Inherited, - Global, - Individual, + INHERITED, + GLOBAL, + INDIVIDUAL, } constructor() : this("") diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/RootProfileConfig.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/RootProfileConfig.kt index 3ba0b163..3e187acc 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/RootProfileConfig.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/RootProfileConfig.kt @@ -74,9 +74,9 @@ fun RootProfileConfig( var expanded by remember { mutableStateOf(false) } val currentNamespace = when (profile.namespace) { - Natives.Profile.Namespace.Inherited.ordinal -> stringResource(R.string.profile_namespace_inherited) - Natives.Profile.Namespace.Global.ordinal -> stringResource(R.string.profile_namespace_global) - Natives.Profile.Namespace.Individual.ordinal -> stringResource(R.string.profile_namespace_individual) + Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited) + Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global) + Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual) else -> stringResource(R.string.profile_namespace_inherited) } ListItem(headlineContent = { @@ -104,21 +104,21 @@ fun RootProfileConfig( DropdownMenuItem( text = { Text(stringResource(R.string.profile_namespace_inherited)) }, onClick = { - onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Inherited.ordinal)) + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal)) expanded = false }, ) DropdownMenuItem( text = { Text(stringResource(R.string.profile_namespace_global)) }, onClick = { - onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Global.ordinal)) + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal)) expanded = false }, ) DropdownMenuItem( text = { Text(stringResource(R.string.profile_namespace_individual)) }, onClick = { - onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Individual.ordinal)) + onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal)) expanded = false }, ) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/TemplateConfig.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/TemplateConfig.kt new file mode 100644 index 00000000..09978f05 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/component/profile/TemplateConfig.kt @@ -0,0 +1,104 @@ +package me.weishu.kernelsu.ui.component.profile + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.ReadMore +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import me.weishu.kernelsu.Natives +import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.util.listAppProfileTemplates +import me.weishu.kernelsu.ui.util.setSepolicy +import me.weishu.kernelsu.ui.viewmodel.getTemplateInfoById + +/** + * @author weishu + * @date 2023/10/21. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TemplateConfig( + profile: Natives.Profile, + onViewTemplate: (id: String) -> Unit = {}, + onProfileChange: (Natives.Profile) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var template by rememberSaveable { + mutableStateOf(profile.rootTemplate ?: "") + } + val profileTemplates = listAppProfileTemplates() + + ListItem(headlineContent = { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.profile_template)) }, + value = template, + onValueChange = {}, + trailingIcon = { + if (expanded) Icon(Icons.Filled.ArrowDropUp, null) + else Icon(Icons.Filled.ArrowDropDown, null) + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + profileTemplates.forEach { tid -> + val templateInfo = + getTemplateInfoById(tid) ?: return@forEach + DropdownMenuItem( + text = { Text(tid) }, + onClick = { + template = tid + if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) { + onProfileChange( + profile.copy( + rootTemplate = tid, + rootUseDefault = false, + uid = templateInfo.uid, + gid = templateInfo.gid, + groups = templateInfo.groups, + capabilities = templateInfo.capabilities, + context = templateInfo.context, + namespace = templateInfo.namespace, + ) + ) + } + expanded = false + }, + trailingIcon = { + IconButton(onClick = { + onViewTemplate(tid) + }) { + Icon(Icons.Filled.ReadMore, null) + } + } + ) + } + } + } + }) +} \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/AppProfile.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/AppProfile.kt index 1c353e4b..8e372fcd 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/AppProfile.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/AppProfile.kt @@ -18,19 +18,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Security import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -61,6 +57,8 @@ import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.SwitchItem import me.weishu.kernelsu.ui.component.profile.AppProfileConfig import me.weishu.kernelsu.ui.component.profile.RootProfileConfig +import me.weishu.kernelsu.ui.component.profile.TemplateConfig +import me.weishu.kernelsu.ui.screen.destinations.TemplateEditorScreenDestination import me.weishu.kernelsu.ui.util.LocalSnackbarHost import me.weishu.kernelsu.ui.util.forceStopApp import me.weishu.kernelsu.ui.util.getSepolicy @@ -68,6 +66,7 @@ import me.weishu.kernelsu.ui.util.launchApp import me.weishu.kernelsu.ui.util.restartApp import me.weishu.kernelsu.ui.util.setSepolicy import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel +import me.weishu.kernelsu.ui.viewmodel.getTemplateInfoById /** * @author weishu @@ -107,9 +106,7 @@ fun AppProfileScreen( appLabel = appInfo.label, appIcon = { AsyncImage( - model = ImageRequest.Builder(context) - .data(appInfo.packageInfo) - .crossfade(true) + model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true) .build(), contentDescription = appInfo.label, modifier = Modifier @@ -119,6 +116,11 @@ fun AppProfileScreen( ) }, profile = profile, + onViewTemplate = { + getTemplateInfoById(it)?.let { info-> + navigator.navigate(TemplateEditorScreenDestination(info)) + } + }, onProfileChange = { scope.launch { if (it.allowSu && !it.rootUseDefault && it.rules.isNotEmpty()) { @@ -138,7 +140,6 @@ fun AppProfileScreen( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppProfileInner( modifier: Modifier = Modifier, @@ -146,6 +147,7 @@ private fun AppProfileInner( appLabel: String, appIcon: @Composable () -> Unit, profile: Natives.Profile, + onViewTemplate: (id: String) -> Unit = {}, onProfileChange: (Natives.Profile) -> Unit, ) { val isRootGranted = profile.allowSu @@ -179,7 +181,7 @@ private fun AppProfileInner( var mode by remember { mutableStateOf(initialMode) } - ProfileBox(mode, false) { + ProfileBox(mode, true) { // template mode shouldn't change profile here! if (it == Mode.Default || it == Mode.Custom) { onProfileChange(profile.copy(rootUseDefault = it == Mode.Default)) @@ -188,43 +190,9 @@ private fun AppProfileInner( } Crossfade(targetState = mode, label = "") { currentMode -> if (currentMode == Mode.Template) { - var expanded by remember { mutableStateOf(false) } - val templateNone = "None" - var template by rememberSaveable { - mutableStateOf( - profile.rootTemplate - ?: templateNone - ) - } - ListItem(headlineContent = { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - OutlinedTextField( - modifier = Modifier.menuAnchor(), - readOnly = true, - label = { Text(stringResource(R.string.profile_template)) }, - value = template, - onValueChange = { - if (template != templateNone) { - onProfileChange( - profile.copy( - rootTemplate = it, - rootUseDefault = false - ) - ) - template = it - } - }, - trailingIcon = { - if (expanded) Icon(Icons.Filled.ArrowDropUp, null) - else Icon(Icons.Filled.ArrowDropDown, null) - }, - ) - // TODO: Template - } - }) + TemplateConfig(profile = profile, + onViewTemplate = onViewTemplate, + onProfileChange = onProfileChange) } else if (mode == Mode.Custom) { RootProfileConfig( fixedName = true, @@ -254,9 +222,7 @@ private fun AppProfileInner( } private enum class Mode(@StringRes private val res: Int) { - Default(R.string.profile_default), - Template(R.string.profile_template), - Custom(R.string.profile_custom); + Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom); val text: String @Composable get() = stringResource(res) @@ -292,8 +258,7 @@ private fun ProfileBox( Divider(thickness = Dp.Hairline) ListItem(headlineContent = { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { FilterChip( selected = mode == Mode.Default, @@ -331,8 +296,7 @@ private fun AppMenuBox(packageName: String, content: @Composable () -> Unit) { touchPoint = it expanded = true } - } - ) { + }) { content() diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Settings.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Settings.kt index 03216b5e..e64754a1 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Settings.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Settings.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.ContactPage +import androidx.compose.material.icons.filled.Fence import androidx.compose.material.icons.filled.RemoveModerator import androidx.compose.material3.* import androidx.compose.runtime.* @@ -27,6 +28,7 @@ import me.weishu.kernelsu.R import me.weishu.kernelsu.ui.component.AboutDialog import me.weishu.kernelsu.ui.component.LoadingDialog import me.weishu.kernelsu.ui.component.SwitchItem +import me.weishu.kernelsu.ui.screen.destinations.AppProfileTemplateScreenDestination import me.weishu.kernelsu.ui.util.LocalDialogHost import me.weishu.kernelsu.ui.util.getBugreportFile @@ -56,6 +58,16 @@ fun SettingScreen(navigator: DestinationsNavigator) { val scope = rememberCoroutineScope() val dialogHost = LocalDialogHost.current + val profileTemplate = stringResource(id = R.string.settings_profile_template) + ListItem( + leadingContent = { Icon(Icons.Filled.Fence, profileTemplate) }, + headlineContent = { Text(profileTemplate) }, + supportingContent = { Text(stringResource(id = R.string.settings_profile_template_summary))}, + modifier = Modifier.clickable { + navigator.navigate(AppProfileTemplateScreenDestination) + } + ) + var umountChecked by rememberSaveable { mutableStateOf(Natives.isDefaultUmountModules()) } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt new file mode 100644 index 00000000..09dc1f5a --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt @@ -0,0 +1,148 @@ +package me.weishu.kernelsu.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.launch +import me.weishu.kernelsu.R +import me.weishu.kernelsu.ui.screen.destinations.TemplateEditorScreenDestination +import me.weishu.kernelsu.ui.viewmodel.TemplateViewModel + +/** + * @author weishu + * @date 2023/10/20. + */ + +@OptIn(ExperimentalMaterialApi::class) +@Destination +@Composable +fun AppProfileTemplateScreen(navigator: DestinationsNavigator) { + val viewModel = viewModel() + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + if (viewModel.templateList.isEmpty()) { + viewModel.fetchTemplates() + } + } + + Scaffold( + topBar = { + TopBar(onBack = { navigator.popBackStack() }, + onSync = { + scope.launch { viewModel.fetchTemplates(true) } + }, + onClickCreate = { + navigator.navigate( + TemplateEditorScreenDestination( + TemplateViewModel.TemplateInfo(), + false + ) + ) + }) + }, + ) { innerPadding -> + val refreshState = rememberPullRefreshState( + refreshing = viewModel.isRefreshing, + onRefresh = { scope.launch { viewModel.fetchTemplates() } }, + ) + Box( + modifier = Modifier + .padding(innerPadding) + .pullRefresh(refreshState) + ) { + LazyColumn(Modifier.fillMaxSize()) { + items(viewModel.templateList, key = { it.id }) { app -> + TemplateItem(navigator, app) + } + } + + PullRefreshIndicator( + refreshing = viewModel.isRefreshing, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TemplateItem( + navigator: DestinationsNavigator, + template: TemplateViewModel.TemplateInfo +) { + ListItem( + modifier = Modifier.clickable { + navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) + }, + headlineContent = { Text(template.name) }, + supportingContent = { + Column { + Text(template.description) + FlowRow { + LabelText(label = "UID: ${template.uid}") + LabelText(label = "GID: ${template.gid}") + LabelText(label = template.context) + if (template.local) { + LabelText(label = "local") + } else { + LabelText(label = "remote") + } + } + } + }) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar(onBack: () -> Unit, onSync: () -> Unit, onClickCreate: () -> Unit) { + TopAppBar( + title = { + Text(stringResource(R.string.settings_profile_template)) + }, + navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.Filled.ArrowBack, contentDescription = null) } + }, + actions = { + IconButton(onClick = onSync) { + Icon(Icons.Filled.Sync, contentDescription = null) + } + IconButton(onClick = onClickCreate) { + Icon(Icons.Filled.Create, contentDescription = null) + } + } + ) +} \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt new file mode 100644 index 00000000..d48cdf87 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt @@ -0,0 +1,345 @@ +package me.weishu.kernelsu.ui.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import me.weishu.kernelsu.Natives +import me.weishu.kernelsu.R +import me.weishu.kernelsu.profile.Capabilities +import me.weishu.kernelsu.profile.Groups +import me.weishu.kernelsu.ui.component.profile.RootProfileConfig +import me.weishu.kernelsu.ui.util.deleteAppProfileTemplate +import me.weishu.kernelsu.ui.util.getAppProfileTemplate +import me.weishu.kernelsu.ui.util.setAppProfileTemplate +import me.weishu.kernelsu.ui.viewmodel.TemplateViewModel +import org.json.JSONArray +import org.json.JSONObject + +/** + * @author weishu + * @date 2023/10/20. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Destination +@Composable +fun TemplateEditorScreen( + navigator: DestinationsNavigator, + initialTemplate: TemplateViewModel.TemplateInfo, + readOnly: Boolean = true, +) { + + val isCreation = initialTemplate.id.isBlank() + val autoSave = !isCreation + + var template by rememberSaveable { + mutableStateOf(initialTemplate) + } + + Scaffold( + topBar = { + val author = + if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else "" + val readOnlyHint = if (readOnly) { + " - ${stringResource(id = R.string.app_profile_template_readonly)}" + } else { + "" + } + val titleSummary = "${initialTemplate.id}$author$readOnlyHint" + + TopBar( + title = if (isCreation) { + stringResource(R.string.app_profile_template_create) + } else if (readOnly) { + stringResource(R.string.app_profile_template_view) + } else { + stringResource(R.string.app_profile_template_edit) + }, + readOnly = readOnly, + summary = titleSummary, + onBack = { navigator.popBackStack() }, + onDelete = { + if (deleteAppProfileTemplate(template.id)) { + navigator.popBackStack() + } + }, + onSave = { + if (saveTemplate(template, isCreation)) { + navigator.popBackStack() + } + }) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .pointerInteropFilter { + // disable click and ripple if readOnly + readOnly + } + ) { + if (isCreation) { + var errorHint by remember { + mutableStateOf("") + } + val idConflictError = stringResource(id = R.string.app_profile_template_id_exist) + val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid) + TextEdit( + label = stringResource(id = R.string.app_profile_template_id), + text = template.id, + errorHint = errorHint, + isError = errorHint.isNotEmpty() + ) { value -> + errorHint = if (isTemplateExist(value)) { + idConflictError + } else if (!isValidTemplateId(value)) { + idInvalidError + } else { + "" + } + template = template.copy(id = value) + } + } + + TextEdit( + label = stringResource(id = R.string.app_profile_template_name), + text = template.name + ) { value -> + template.copy(name = value).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + } + TextEdit( + label = stringResource(id = R.string.app_profile_template_description), + text = template.description + ) { value -> + template.copy(description = value).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + } + + RootProfileConfig(fixedName = true, + profile = toNativeProfile(template), + onProfileChange = { + template.copy( + uid = it.uid, + gid = it.gid, + groups = it.groups, + capabilities = it.capabilities, + context = it.context, + namespace = it.namespace, + rules = it.rules.split("\n") + ).run { + if (autoSave) { + if (!saveTemplate(this)) { + // failed + return@run + } + } + template = this + } + }) + } + } +} + +fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile { + return Natives.Profile().copy(rootTemplate = templateInfo.id, + uid = templateInfo.uid, + gid = templateInfo.gid, + groups = templateInfo.groups, + capabilities = templateInfo.capabilities, + context = templateInfo.context, + namespace = templateInfo.namespace, + rules = templateInfo.rules.joinToString("\n").ifBlank { "" }) +} + +fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean { + if (template.id.isBlank()) { + return false + } + + if (!isValidTemplateId(template.id)) { + return false + } + + return true +} + +fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean { + if (!isTemplateValid(template)) { + return false + } + + if (isCreation && isTemplateExist(template.id)) { + return false + } + + template.apply { + val json = JSONObject().apply { + put("id", id) + put("name", name.ifBlank { id }) + put("description", description.ifBlank { id }) + put("local", true) + + put("uid", uid) + put("gid", gid) + put("groups", JSONArray().apply { + Groups.values().forEach { group -> + if (group.gid in groups) { + put(group.name) + } + } + }) + put("capabilities", JSONArray().apply { + Capabilities.values().forEach { capability -> + if (capability.cap in capabilities) { + put(capability.name) + } + } + }) + put("context", context) + put("namespace", Natives.Profile.Namespace.values()[namespace].name) + put("rules", JSONArray().apply { + rules.forEach { rule -> + put(rule) + } + }) + } + return setAppProfileTemplate(id, json.toString()) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + title: String, + readOnly: Boolean, + summary: String = "", + onBack: () -> Unit, + onDelete: () -> Unit = {}, + onSave: () -> Unit = {} +) { + TopAppBar(title = { + Column { + Text(title) + if (summary.isNotBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + }, navigationIcon = { + IconButton( + onClick = onBack + ) { Icon(Icons.Filled.ArrowBack, contentDescription = null) } + }, actions = { + if (readOnly) { + return@TopAppBar + } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.DeleteForever, + contentDescription = stringResource(id = R.string.app_profile_template_delete) + ) + } + IconButton(onClick = onSave) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(id = R.string.app_profile_template_save) + ) + } + }) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun TextEdit( + label: String, + text: String, + errorHint: String = "", + isError: Boolean = false, + onValueChange: (String) -> Unit = {} +) { + ListItem(headlineContent = { + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = text, + modifier = Modifier.fillMaxWidth(), + label = { Text(label) }, + suffix = + if (errorHint.isNotBlank()) { + { + Text( + text = if (isError) errorHint else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } else { + null + }, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + }), + onValueChange = onValueChange + ) + }) +} + +private fun isValidTemplateId(id: String): Boolean { + return Regex("""^([A-Za-z]{1}[A-Za-z\d_]*\.)*[A-Za-z][A-Za-z\d_]*$""").matches(id) +} + +private fun isTemplateExist(id: String): Boolean { + return getAppProfileTemplate(id).isNotBlank() +} \ No newline at end of file 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 39fbb28d..2af97e03 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 @@ -91,7 +91,12 @@ fun uninstallModule(id: String): Boolean { return result } -fun installModule(uri: Uri, onFinish: (Boolean) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit): Boolean { +fun installModule( + uri: Uri, + onFinish: (Boolean) -> Unit, + onStdout: (String) -> Unit, + onStderr: (String) -> Unit +): Boolean { val resolver = ksuApp.contentResolver with(resolver.openInputStream(uri)) { val file = File(ksuApp.cacheDir, "module.zip") @@ -115,7 +120,8 @@ fun installModule(uri: Uri, onFinish: (Boolean) -> Unit, onStdout: (String) -> U } val result = - shell.newJob().add("${getKsuDaemonPath()} $cmd").to(stdoutCallback, stderrCallback).exec() + shell.newJob().add("${getKsuDaemonPath()} $cmd").to(stdoutCallback, stderrCallback) + .exec() Log.i("KernelSU", "install module $uri result: $result") file.delete() @@ -153,14 +159,16 @@ fun isSepolicyValid(rules: String?): Boolean { } val shell = getRootShell() val result = - shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null).exec() + shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null) + .exec() return result.isSuccess } fun getSepolicy(pkg: String): String { val shell = getRootShell() val result = - shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null).exec() + shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null) + .exec() Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}") return result.out.joinToString("\n") } @@ -168,11 +176,39 @@ fun getSepolicy(pkg: String): String { fun setSepolicy(pkg: String, rules: String): Boolean { val shell = getRootShell() val result = - shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'").to(ArrayList(), null).exec() + shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'") + .to(ArrayList(), null).exec() Log.i(TAG, "set sepolicy result: ${result.code}") return result.isSuccess } +fun listAppProfileTemplates(): List { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile list-templates").to(ArrayList(), null) + .exec().out +} + +fun getAppProfileTemplate(id: String): String { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile get-template '${id}'") + .to(ArrayList(), null) + .exec().out.joinToString("\n") +} + +fun setAppProfileTemplate(id: String, template: String): Boolean { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile set-template '${id}' '${template}'") + .to(ArrayList(), null) + .exec().isSuccess +} + +fun deleteAppProfileTemplate(id: String): Boolean { + val shell = getRootShell() + return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'") + .to(ArrayList(), null) + .exec().isSuccess +} + fun forceStopApp(packageName: String) { val shell = getRootShell() val result = shell.newJob().add("am force-stop $packageName").exec() @@ -182,7 +218,8 @@ fun forceStopApp(packageName: String) { fun launchApp(packageName: String) { val shell = getRootShell() - val result = shell.newJob().add("monkey -p $packageName -c android.intent.category.LAUNCHER 1").exec() + val result = + shell.newJob().add("monkey -p $packageName -c android.intent.category.LAUNCHER 1").exec() Log.i(TAG, "launch $packageName result: $result") } diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt new file mode 100644 index 00000000..3853ff29 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt @@ -0,0 +1,208 @@ +package me.weishu.kernelsu.ui.viewmodel + +import android.os.Parcelable +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import me.weishu.kernelsu.Natives +import me.weishu.kernelsu.profile.Capabilities +import me.weishu.kernelsu.profile.Groups +import me.weishu.kernelsu.ui.util.getAppProfileTemplate +import me.weishu.kernelsu.ui.util.listAppProfileTemplates +import me.weishu.kernelsu.ui.util.setAppProfileTemplate +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.text.Collator +import java.util.Locale + +/** + * @author weishu + * @date 2023/10/20. + */ + +//const val TEMPLATE_INDEX_URL = "https://kernelsu.org/templates/index.json" +//const val TEMPLATE_URL = "https://kernelsu.org/templates/%s" + +const val TEMPLATE_INDEX_URL = "http://192.168.31.99/templates/index.json" +const val TEMPLATE_URL = "http://192.168.31.99/templates/%s" +const val TAG = "TemplateViewModel" + +class TemplateViewModel : ViewModel() { + companion object { + + private var templates by mutableStateOf>(emptyList()) + } + + @Parcelize + data class TemplateInfo( + val id: String = "", + val name: String = "", + val description: String = "", + val author: String = "", + val local: Boolean = true, + + val namespace: Int = Natives.Profile.Namespace.INHERITED.ordinal, + val uid: Int = 0, + val gid: Int = 0, + val groups: List = mutableListOf(), + val capabilities: List = mutableListOf(), + val context: String = "u:r:su:s0", + val rules: List = mutableListOf(), + ) : Parcelable + + var isRefreshing by mutableStateOf(false) + private set + + val templateList by derivedStateOf { + val comparator = compareBy(TemplateInfo::local).then( + compareBy( + Collator.getInstance(Locale.getDefault()), TemplateInfo::name + ) + ) + templates.sortedWith(comparator).apply { + isRefreshing = false + } + } + + suspend fun fetchTemplates(sync: Boolean = false) { + isRefreshing = true + withContext(Dispatchers.IO) { + val localTemplateIds = listAppProfileTemplates() + Log.i(TAG, "localTemplateIds: $localTemplateIds") + if (localTemplateIds.isEmpty() || sync) { + // if no templates, fetch remote templates + fetchRemoteTemplates() + } + + // fetch templates again + templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById) + + isRefreshing = false + } + } +} + +private fun fetchRemoteTemplates() { + OkHttpClient().newCall( + Request.Builder().url(TEMPLATE_INDEX_URL).build() + ).runCatching { + execute().use { response -> + if (!response.isSuccessful) { + return + } + val remoteTemplateIds = JSONArray(response.body!!.string()) + Log.i(TAG, "fetchRemoteTemplates: $remoteTemplateIds") + 0.until(remoteTemplateIds.length()).forEach { i -> + val id = remoteTemplateIds.getString(i) + val templateJson = OkHttpClient().newCall( + Request.Builder().url(TEMPLATE_URL.format(id)).build() + ).runCatching { + execute().use { response -> + if (!response.isSuccessful) { + return@forEach + } + response.body!!.string() + } + }.getOrNull() ?: return@forEach + Log.i(TAG, "template: $templateJson") + + // validate remote template + runCatching { + val json = JSONObject(templateJson) + fromJSON(json)?.let { + // force local template + json.put("local", false) + setAppProfileTemplate(id, json.toString()) + } + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + return@forEach + } + } + } + }.onFailure { + Log.e(TAG, "fetchRemoteTemplates error", it) + } +} + +private fun JSONArray.mapCatching( + transform: (T) -> R, onFail: (Throwable) -> Unit +): List { + return List(length()) { i -> get(i) as T }.mapNotNull { element -> + runCatching { + transform(element) + }.onFailure(onFail).getOrNull() + } +} + +private inline fun > getEnumOrdinals( + jsonArray: JSONArray, enumClass: Class +): List { + return jsonArray.mapCatching({ name -> + enumValueOf(name.uppercase()) + }, { + Log.e(TAG, "ignore invalid enum ${enumClass.simpleName}: $it", it) + }) +} + +fun getTemplateInfoById(id: String): TemplateViewModel.TemplateInfo? { + return runCatching { + fromJSON(JSONObject(getAppProfileTemplate(id))) + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + }.getOrNull() +} + +private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo? { + return runCatching { + val groupsJsonArray = templateJson.getJSONArray("groups") + val capabilitiesJsonArray = templateJson.getJSONArray("capabilities") + val rulesJsonArray = templateJson.optJSONArray("rules") + val templateInfo = TemplateViewModel.TemplateInfo( + id = templateJson.getString("id"), + name = templateJson.getString("name"), + description = templateJson.getString("description"), + local = templateJson.getBoolean("local"), + namespace = Natives.Profile.Namespace.valueOf( + templateJson.getString("namespace").uppercase() + ).ordinal, + uid = templateJson.getInt("uid"), + gid = templateJson.getInt("gid"), + groups = getEnumOrdinals(groupsJsonArray, Groups::class.java).map { it.gid }, + capabilities = getEnumOrdinals( + capabilitiesJsonArray, Capabilities::class.java + ).map { it.cap }, + context = templateJson.getString("context"), + rules = rulesJsonArray?.mapCatching({ it }, { + Log.e(TAG, "ignore invalid rule: $it", it) + }).orEmpty() + ) + templateInfo + }.onFailure { + Log.e(TAG, "ignore invalid template: $it", it) + }.getOrNull() +} + +fun generateTemplates() { + val templateJson = JSONObject() + templateJson.put("id", "com.example") + templateJson.put("name", "Example") + templateJson.put("description", "This is an example template") + templateJson.put("local", true) + templateJson.put("namespace", Natives.Profile.Namespace.INHERITED.name) + templateJson.put("uid", 0) + templateJson.put("gid", 0) + + templateJson.put("groups", JSONArray().apply { put(Groups.INET.name) }) + templateJson.put("capabilities", JSONArray().apply { put(Capabilities.CAP_NET_RAW.name) }) + templateJson.put("context", "u:r:su:s0") + Log.i(TAG, "$templateJson") +} \ 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 376924fc..ce7f2d06 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -80,4 +80,17 @@ 重新启动 为:%s 更新翻译失败 更新日志 + App Profile 模版 + 管理本地和在线的 App Profile 模版 + 创建模版 + 编辑模版 + 模版 id + 模版 id 不合法! + 名字 + 描述 + 保存 + 删除 + 查看模版 + 只读 + 模版 id 已存在! \ 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 5c1fdbdd..035171e1 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -85,4 +85,17 @@ Restart Failed to update SELinux rules for: %s Changelog + App Profile Template + Manage local and online template of App Profile + Create Template + Edit Template + id + Invalid template id + Name + Description + Save + Delete + View Template + readonly + template id already exists! diff --git a/userspace/ksud/src/cli.rs b/userspace/ksud/src/cli.rs index 410195a9..cf414912 100644 --- a/userspace/ksud/src/cli.rs +++ b/userspace/ksud/src/cli.rs @@ -148,20 +148,26 @@ enum Profile { policy: String, }, - /// get template of + /// get template of GetTemplate { - /// package name - package: String, + /// template id + id: String, }, - /// set template of to