manager: support App Profile template
This commit is contained in:
@@ -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("")
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user