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 external fun setAppProfile(profile: Profile?): Boolean
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
private const val ROOT_DEFAULT_PROFILE_KEY = "#"
private const val NOBODY_UID = 9999 private const val NOBODY_UID = 9999
fun setDefaultUmountModules(umountModules: Boolean): Boolean { fun setDefaultUmountModules(umountModules: Boolean): Boolean {
@@ -90,16 +89,16 @@ object Natives {
val groups: List<Int> = mutableListOf(), val groups: List<Int> = mutableListOf(),
val capabilities: List<Int> = mutableListOf(), val capabilities: List<Int> = mutableListOf(),
val context: String = "u:r:su:s0", val context: String = "u:r:su:s0",
val namespace: Int = Namespace.Inherited.ordinal, val namespace: Int = Namespace.INHERITED.ordinal,
val nonRootUseDefault: Boolean = true, val nonRootUseDefault: Boolean = true,
val umountModules: Boolean = true, val umountModules: Boolean = true,
var rules: String = "", // this field is save in ksud!! var rules: String = "", // this field is save in ksud!!
) : Parcelable { ) : Parcelable {
enum class Namespace { enum class Namespace {
Inherited, INHERITED,
Global, GLOBAL,
Individual, INDIVIDUAL,
} }
constructor() : this("") constructor() : this("")

View File

@@ -74,9 +74,9 @@ fun RootProfileConfig(
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val currentNamespace = when (profile.namespace) { val currentNamespace = when (profile.namespace) {
Natives.Profile.Namespace.Inherited.ordinal -> stringResource(R.string.profile_namespace_inherited) 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.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
Natives.Profile.Namespace.Individual.ordinal -> stringResource(R.string.profile_namespace_individual) Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
else -> stringResource(R.string.profile_namespace_inherited) else -> stringResource(R.string.profile_namespace_inherited)
} }
ListItem(headlineContent = { ListItem(headlineContent = {
@@ -104,21 +104,21 @@ fun RootProfileConfig(
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_inherited)) }, text = { Text(stringResource(R.string.profile_namespace_inherited)) },
onClick = { onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Inherited.ordinal)) onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
expanded = false expanded = false
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_global)) }, text = { Text(stringResource(R.string.profile_namespace_global)) },
onClick = { onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Global.ordinal)) onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
expanded = false expanded = false
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_individual)) }, text = { Text(stringResource(R.string.profile_namespace_individual)) },
onClick = { onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.Individual.ordinal)) onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
expanded = false 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.AccountCircle
import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.ArrowBack 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.material.icons.filled.Security
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar 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.SwitchItem
import me.weishu.kernelsu.ui.component.profile.AppProfileConfig import me.weishu.kernelsu.ui.component.profile.AppProfileConfig
import me.weishu.kernelsu.ui.component.profile.RootProfileConfig 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.LocalSnackbarHost
import me.weishu.kernelsu.ui.util.forceStopApp import me.weishu.kernelsu.ui.util.forceStopApp
import me.weishu.kernelsu.ui.util.getSepolicy 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.restartApp
import me.weishu.kernelsu.ui.util.setSepolicy import me.weishu.kernelsu.ui.util.setSepolicy
import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel import me.weishu.kernelsu.ui.viewmodel.SuperUserViewModel
import me.weishu.kernelsu.ui.viewmodel.getTemplateInfoById
/** /**
* @author weishu * @author weishu
@@ -107,9 +106,7 @@ fun AppProfileScreen(
appLabel = appInfo.label, appLabel = appInfo.label,
appIcon = { appIcon = {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true)
.data(appInfo.packageInfo)
.crossfade(true)
.build(), .build(),
contentDescription = appInfo.label, contentDescription = appInfo.label,
modifier = Modifier modifier = Modifier
@@ -119,6 +116,11 @@ fun AppProfileScreen(
) )
}, },
profile = profile, profile = profile,
onViewTemplate = {
getTemplateInfoById(it)?.let { info->
navigator.navigate(TemplateEditorScreenDestination(info))
}
},
onProfileChange = { onProfileChange = {
scope.launch { scope.launch {
if (it.allowSu && !it.rootUseDefault && it.rules.isNotEmpty()) { if (it.allowSu && !it.rootUseDefault && it.rules.isNotEmpty()) {
@@ -138,7 +140,6 @@ fun AppProfileScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AppProfileInner( private fun AppProfileInner(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -146,6 +147,7 @@ private fun AppProfileInner(
appLabel: String, appLabel: String,
appIcon: @Composable () -> Unit, appIcon: @Composable () -> Unit,
profile: Natives.Profile, profile: Natives.Profile,
onViewTemplate: (id: String) -> Unit = {},
onProfileChange: (Natives.Profile) -> Unit, onProfileChange: (Natives.Profile) -> Unit,
) { ) {
val isRootGranted = profile.allowSu val isRootGranted = profile.allowSu
@@ -179,7 +181,7 @@ private fun AppProfileInner(
var mode by remember { var mode by remember {
mutableStateOf(initialMode) mutableStateOf(initialMode)
} }
ProfileBox(mode, false) { ProfileBox(mode, true) {
// template mode shouldn't change profile here! // template mode shouldn't change profile here!
if (it == Mode.Default || it == Mode.Custom) { if (it == Mode.Default || it == Mode.Custom) {
onProfileChange(profile.copy(rootUseDefault = it == Mode.Default)) onProfileChange(profile.copy(rootUseDefault = it == Mode.Default))
@@ -188,43 +190,9 @@ private fun AppProfileInner(
} }
Crossfade(targetState = mode, label = "") { currentMode -> Crossfade(targetState = mode, label = "") { currentMode ->
if (currentMode == Mode.Template) { if (currentMode == Mode.Template) {
var expanded by remember { mutableStateOf(false) } TemplateConfig(profile = profile,
val templateNone = "None" onViewTemplate = onViewTemplate,
var template by rememberSaveable { onProfileChange = onProfileChange)
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
}
})
} else if (mode == Mode.Custom) { } else if (mode == Mode.Custom) {
RootProfileConfig( RootProfileConfig(
fixedName = true, fixedName = true,
@@ -254,9 +222,7 @@ private fun AppProfileInner(
} }
private enum class Mode(@StringRes private val res: Int) { private enum class Mode(@StringRes private val res: Int) {
Default(R.string.profile_default), Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom);
Template(R.string.profile_template),
Custom(R.string.profile_custom);
val text: String val text: String
@Composable get() = stringResource(res) @Composable get() = stringResource(res)
@@ -292,8 +258,7 @@ private fun ProfileBox(
Divider(thickness = Dp.Hairline) Divider(thickness = Dp.Hairline)
ListItem(headlineContent = { ListItem(headlineContent = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly
horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
FilterChip( FilterChip(
selected = mode == Mode.Default, selected = mode == Mode.Default,
@@ -331,8 +296,7 @@ private fun AppMenuBox(packageName: String, content: @Composable () -> Unit) {
touchPoint = it touchPoint = it
expanded = true expanded = true
} }
} }) {
) {
content() 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.ArrowBack
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.ContactPage import androidx.compose.material.icons.filled.ContactPage
import androidx.compose.material.icons.filled.Fence
import androidx.compose.material.icons.filled.RemoveModerator import androidx.compose.material.icons.filled.RemoveModerator
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.AboutDialog
import me.weishu.kernelsu.ui.component.LoadingDialog import me.weishu.kernelsu.ui.component.LoadingDialog
import me.weishu.kernelsu.ui.component.SwitchItem 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.LocalDialogHost
import me.weishu.kernelsu.ui.util.getBugreportFile import me.weishu.kernelsu.ui.util.getBugreportFile
@@ -56,6 +58,16 @@ fun SettingScreen(navigator: DestinationsNavigator) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val dialogHost = LocalDialogHost.current 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 { var umountChecked by rememberSaveable {
mutableStateOf(Natives.isDefaultUmountModules()) 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 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 val resolver = ksuApp.contentResolver
with(resolver.openInputStream(uri)) { with(resolver.openInputStream(uri)) {
val file = File(ksuApp.cacheDir, "module.zip") val file = File(ksuApp.cacheDir, "module.zip")
@@ -115,7 +120,8 @@ fun installModule(uri: Uri, onFinish: (Boolean) -> Unit, onStdout: (String) -> U
} }
val result = 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") Log.i("KernelSU", "install module $uri result: $result")
file.delete() file.delete()
@@ -153,14 +159,16 @@ fun isSepolicyValid(rules: String?): Boolean {
} }
val shell = getRootShell() val shell = getRootShell()
val result = 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 return result.isSuccess
} }
fun getSepolicy(pkg: String): String { fun getSepolicy(pkg: String): String {
val shell = getRootShell() val shell = getRootShell()
val result = 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}") Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}")
return result.out.joinToString("\n") return result.out.joinToString("\n")
} }
@@ -168,11 +176,39 @@ fun getSepolicy(pkg: String): String {
fun setSepolicy(pkg: String, rules: String): Boolean { fun setSepolicy(pkg: String, rules: String): Boolean {
val shell = getRootShell() val shell = getRootShell()
val result = 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}") Log.i(TAG, "set sepolicy result: ${result.code}")
return result.isSuccess 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) { fun forceStopApp(packageName: String) {
val shell = getRootShell() val shell = getRootShell()
val result = shell.newJob().add("am force-stop $packageName").exec() val result = shell.newJob().add("am force-stop $packageName").exec()
@@ -182,7 +218,8 @@ fun forceStopApp(packageName: String) {
fun launchApp(packageName: String) { fun launchApp(packageName: String) {
val shell = getRootShell() 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") 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="restart_app">重新启动</string>
<string name="failed_to_update_sepolicy">为:%s 更新翻译失败</string> <string name="failed_to_update_sepolicy">为:%s 更新翻译失败</string>
<string name="module_changelog">更新日志</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> </resources>

View File

@@ -85,4 +85,17 @@
<string name="restart_app">Restart</string> <string name="restart_app">Restart</string>
<string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string> <string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string>
<string name="module_changelog">Changelog</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> </resources>

View File

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

View File

@@ -18,22 +18,32 @@ pub fn get_sepolicy(pkg: String) -> Result<()> {
Ok(()) 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)?; 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)?; std::fs::write(template_file, template)?;
Ok(()) Ok(())
} }
pub fn get_template(name: String) -> Result<()> { pub fn get_template(id: String) -> Result<()> {
let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(name); let template_file = Path::new(defs::PROFILE_TEMPLATE_DIR).join(id);
let template = std::fs::read_to_string(template_file)?; let template = std::fs::read_to_string(template_file)?;
println!("{template}"); println!("{template}");
Ok(()) 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<()> { 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 { for template in templates {
let template = template?; let template = template?;
let template = template.file_name(); let template = template.file_name();