manager: support App Profile template

This commit is contained in:
weishu
2023-10-21 13:19:59 +08:00
parent a4fb9e4031
commit 9b294682b0
13 changed files with 946 additions and 86 deletions

View File

@@ -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<Int> = mutableListOf(),
val capabilities: List<Int> = 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("")

View File

@@ -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
},
)

View File

@@ -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)
}
}
)
}
}
}
})
}

View File

@@ -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()

View File

@@ -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())
}

View File

@@ -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<TemplateViewModel>()
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)
}
}
)
}

View File

@@ -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()
}

View File

@@ -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<String> {
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")
}

View File

@@ -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<List<TemplateInfo>>(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<Int> = mutableListOf(),
val capabilities: List<Int> = mutableListOf(),
val context: String = "u:r:su:s0",
val rules: List<String> = 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 <T, R> JSONArray.mapCatching(
transform: (T) -> R, onFail: (Throwable) -> Unit
): List<R> {
return List(length()) { i -> get(i) as T }.mapNotNull { element ->
runCatching {
transform(element)
}.onFailure(onFail).getOrNull()
}
}
private inline fun <reified T : Enum<T>> getEnumOrdinals(
jsonArray: JSONArray, enumClass: Class<T>
): List<T> {
return jsonArray.mapCatching<String, T>({ 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<String, String>({ 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")
}

View File

@@ -80,4 +80,17 @@
<string name="restart_app">重新启动</string>
<string name="failed_to_update_sepolicy">为:%s 更新翻译失败</string>
<string name="module_changelog">更新日志</string>
<string name="settings_profile_template">App Profile 模版</string>
<string name="settings_profile_template_summary">管理本地和在线的 App Profile 模版</string>
<string name="app_profile_template_create">创建模版</string>
<string name="app_profile_template_edit">编辑模版</string>
<string name="app_profile_template_id">模版 id</string>
<string name="app_profile_template_id_invalid">模版 id 不合法!</string>
<string name="app_profile_template_name">名字</string>
<string name="app_profile_template_description">描述</string>
<string name="app_profile_template_save">保存</string>
<string name="app_profile_template_delete">删除</string>
<string name="app_profile_template_view">查看模版</string>
<string name="app_profile_template_readonly">只读</string>
<string name="app_profile_template_id_exist">模版 id 已存在!</string>
</resources>

View File

@@ -85,4 +85,17 @@
<string name="restart_app">Restart</string>
<string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string>
<string name="module_changelog">Changelog</string>
<string name="settings_profile_template">App Profile Template</string>
<string name="settings_profile_template_summary">Manage local and online template of App Profile</string>
<string name="app_profile_template_create">Create Template</string>
<string name="app_profile_template_edit">Edit Template</string>
<string name="app_profile_template_id">id</string>
<string name="app_profile_template_id_invalid">Invalid template id</string>
<string name="app_profile_template_name">Name</string>
<string name="app_profile_template_description">Description</string>
<string name="app_profile_template_save">Save</string>
<string name="app_profile_template_delete">Delete</string>
<string name="app_profile_template_view">View Template</string>
<string name="app_profile_template_readonly">readonly</string>
<string name="app_profile_template_id_exist">template id already exists!</string>
</resources>

View File

@@ -148,20 +148,26 @@ enum Profile {
policy: String,
},
/// get template of <package-name>
/// get template of <id>
GetTemplate {
/// package name
package: String,
/// template id
id: String,
},
/// set template of <package-name> to <template>
/// set template of <id> to <template string>
SetTemplate {
/// package name
package: String,
/// template
/// template id
id: String,
/// template string
template: String,
},
/// delete template of <id>
DeleteTemplate {
/// template id
id: String,
},
/// list all templates
ListTemplates,
}
@@ -217,10 +223,11 @@ pub fn run() -> Result<()> {
Profile::SetSepolicy { package, policy } => {
crate::profile::set_sepolicy(package, policy)
}
Profile::GetTemplate { package } => crate::profile::get_template(package),
Profile::SetTemplate { package, template } => {
crate::profile::set_template(package, template)
}
Profile::GetTemplate { id } => crate::profile::get_template(id),
Profile::SetTemplate { id, template } => {
crate::profile::set_template(id, template)
},
Profile::DeleteTemplate { id } => crate::profile::delete_template(id),
Profile::ListTemplates => crate::profile::list_templates(),
},

View File

@@ -18,22 +18,32 @@ pub fn get_sepolicy(pkg: String) -> Result<()> {
Ok(())
}
pub fn set_template(name: String, template: String) -> Result<()> {
// ksud doesn't guarteen the correctness of template, it just save
pub fn set_template(id: String, template: String) -> Result<()> {
ensure_dir_exists(defs::PROFILE_TEMPLATE_DIR)?;
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(name);
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(id);
std::fs::write(template_file, template)?;
Ok(())
}
pub fn get_template(name: String) -> Result<()> {
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(name);
pub fn get_template(id: String) -> Result<()> {
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(id);
let template = std::fs::read_to_string(template_file)?;
println!("{template}");
Ok(())
}
pub fn delete_template(id: String) -> Result<()> {
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(id);
std::fs::remove_file(template_file)?;
Ok(())
}
pub fn list_templates() -> Result<()> {
let templates = std::fs::read_dir(defs::PROFILE_TEMPLATE_DIR)?;
let templates = std::fs::read_dir(defs::PROFILE_TEMPLATE_DIR);
let Ok(templates) = templates else {
return Ok(())
};
for template in templates {
let template = template?;
let template = template.file_name();