diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt
index ed1f00b8..bfb961ef 100644
--- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt
+++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/Template.kt
@@ -1,5 +1,6 @@
package me.weishu.kernelsu.ui.screen
+import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -11,13 +12,17 @@ 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.Add
import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material.icons.filled.Create
+import androidx.compose.material.icons.filled.ImportExport
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.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -27,10 +32,17 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -71,18 +83,53 @@ fun AppProfileTemplateScreen(
Scaffold(
topBar = {
+ val clipboardManager = LocalClipboardManager.current
+ val context = LocalContext.current
+ val showToast = fun(msg: String) {
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ }
TopBar(onBack = { navigator.popBackStack() },
onSync = {
scope.launch { viewModel.fetchTemplates(true) }
},
- onClickCreate = {
+ onImport = {
+ clipboardManager.getText()?.text?.let {
+ scope.launch {
+ viewModel.importTemplates(it, {
+ showToast(context.getString(R.string.app_profile_template_import_success))
+ scope.launch {
+ viewModel.fetchTemplates(false)
+ }
+ }, showToast)
+ }
+ }
+ },
+ onExport = {
+ scope.launch {
+ viewModel.exportTemplates(
+ {
+ showToast(context.getString(R.string.app_profile_template_export_empty))
+ }
+ ) {
+ clipboardManager.setText(AnnotatedString(it))
+ }
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ onClick = {
navigator.navigate(
TemplateEditorScreenDestination(
TemplateViewModel.TemplateInfo(),
false
)
)
- })
+ },
+ icon = { Icon(Icons.Filled.Add, null) },
+ text = { Text(stringResource(id = R.string.app_profile_template_create)) },
+ )
},
) { innerPadding ->
val refreshState = rememberPullRefreshState(
@@ -146,7 +193,12 @@ private fun TemplateItem(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun TopBar(onBack: () -> Unit, onSync: () -> Unit, onClickCreate: () -> Unit) {
+private fun TopBar(
+ onBack: () -> Unit,
+ onSync: () -> Unit = {},
+ onImport: () -> Unit = {},
+ onExport: () -> Unit = {}
+) {
TopAppBar(
title = {
Text(stringResource(R.string.settings_profile_template))
@@ -158,10 +210,37 @@ private fun TopBar(onBack: () -> Unit, onSync: () -> Unit, onClickCreate: () ->
},
actions = {
IconButton(onClick = onSync) {
- Icon(Icons.Filled.Sync, contentDescription = null)
+ Icon(
+ Icons.Filled.Sync,
+ contentDescription = stringResource(id = R.string.app_profile_template_sync)
+ )
}
- IconButton(onClick = onClickCreate) {
- Icon(Icons.Filled.Create, contentDescription = null)
+
+ var showDropdown by remember { mutableStateOf(false) }
+ IconButton(onClick = {
+ showDropdown = true
+ }) {
+ Icon(
+ imageVector = Icons.Filled.ImportExport,
+ contentDescription = stringResource(id = R.string.app_profile_import_export)
+ )
+
+ DropdownMenu(expanded = showDropdown, onDismissRequest = {
+ showDropdown = false
+ }) {
+ DropdownMenuItem(text = {
+ Text(stringResource(id = R.string.app_profile_import_from_clipboard))
+ }, onClick = {
+ onImport()
+ showDropdown = false
+ })
+ DropdownMenuItem(text = {
+ Text(stringResource(id = R.string.app_profile_export_to_clipboard))
+ }, onClick = {
+ onExport()
+ showDropdown = false
+ })
+ }
}
}
)
diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt
index 3ff239b0..5059244f 100644
--- a/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt
+++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/screen/TemplateEditor.kt
@@ -1,5 +1,6 @@
package me.weishu.kernelsu.ui.screen
+import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,6 +31,7 @@ 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.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
@@ -45,6 +47,7 @@ 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 me.weishu.kernelsu.ui.viewmodel.toJSON
import org.json.JSONArray
import org.json.JSONObject
@@ -82,6 +85,8 @@ fun TemplateEditorScreen(
""
}
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
+ val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
+ val context = LocalContext.current
TopBar(
title = if (isCreation) {
@@ -102,6 +107,8 @@ fun TemplateEditorScreen(
onSave = {
if (saveTemplate(template, isCreation)) {
navigator.navigateBack(result = true)
+ } else {
+ Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
}
})
},
@@ -224,39 +231,9 @@ fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean =
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())
- }
+ val json = template.toJSON()
+ json.put("local", true)
+ return setAppProfileTemplate(template.id, json.toString())
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt
index 55895ec8..44f451ba 100644
--- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt
+++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/TemplateViewModel.kt
@@ -85,6 +85,53 @@ class TemplateViewModel : ViewModel() {
isRefreshing = false
}
}
+
+ suspend fun importTemplates(
+ templates: String,
+ onSuccess: () -> Unit,
+ onFailure: (String) -> Unit
+ ) {
+ withContext(Dispatchers.IO) {
+ runCatching {
+ JSONArray(templates)
+ }.getOrElse {
+ runCatching {
+ val json = JSONObject(templates)
+ JSONArray().apply { put(json) }
+ }.getOrElse {
+ onFailure("invalid templates: $templates")
+ return@withContext
+ }
+ }.let {
+ 0.until(it.length()).forEach { i ->
+ runCatching {
+ val template = it.getJSONObject(i)
+ val id = template.getString("id")
+ template.put("local", true)
+ setAppProfileTemplate(id, template.toString())
+ }.onFailure { e ->
+ Log.e(TAG, "ignore invalid template: $it", e)
+ }
+ }
+ onSuccess()
+ }
+ }
+ }
+
+ suspend fun exportTemplates(onTemplateEmpty: () -> Unit, callback: (String) -> Unit) {
+ withContext(Dispatchers.IO) {
+ val templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById).filter {
+ it.local
+ }
+ templates.ifEmpty {
+ onTemplateEmpty()
+ return@withContext
+ }
+ JSONArray(templates.map {
+ it.toJSON()
+ }).toString().let(callback)
+ }
+ }
}
private fun fetchRemoteTemplates() {
@@ -205,6 +252,50 @@ private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo?
}.getOrNull()
}
+fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
+ val template = this
+ return JSONObject().apply {
+
+ put("id", template.id)
+ put("name", template.name.ifBlank { template.id })
+ put("description", template.description.ifBlank { template.id })
+ if (template.author.isNotEmpty()) {
+ put("author", template.author)
+ }
+ put("namespace", Natives.Profile.Namespace.values()[template.namespace].name)
+ put("uid", template.uid)
+ put("gid", template.gid)
+
+ if (template.groups.isNotEmpty()) {
+ put("groups", JSONArray(
+ Groups.values().filter {
+ template.groups.contains(it.gid)
+ }.map {
+ it.name
+ }
+ ))
+ }
+
+ if (template.capabilities.isNotEmpty()) {
+ put("capabilities", JSONArray(
+ Capabilities.values().filter {
+ template.capabilities.contains(it.cap)
+ }.map {
+ it.name
+ }
+ ))
+ }
+
+ if (template.context.isNotEmpty()) {
+ put("context", template.context)
+ }
+
+ if (template.rules.isNotEmpty()) {
+ put("rules", JSONArray(template.rules))
+ }
+ }
+}
+
@Suppress("unused")
fun generateTemplates() {
val templateJson = JSONObject()
diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml
index ce7f2d06..506f696e 100644
--- a/manager/app/src/main/res/values-zh-rCN/strings.xml
+++ b/manager/app/src/main/res/values-zh-rCN/strings.xml
@@ -93,4 +93,11 @@
查看模版
只读
模版 id 已存在!
+ 导入/导出
+ 从剪切板导入
+ 导出到剪切板
+ 没有本地模版可以导出!
+ 导入成功!
+ 同步在线规则
+ 模版保存失败!
\ No newline at end of file
diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml
index 04249b4e..5633aa8f 100644
--- a/manager/app/src/main/res/values/strings.xml
+++ b/manager/app/src/main/res/values/strings.xml
@@ -95,4 +95,11 @@
View Template
readonly
template id already exists!
+ Import/Export
+ Import from clipboard
+ Export to clipboard
+ Can not find local template to export!
+ Imported successfully
+ Sync online templates
+ Failed to save template