From 7371eae382f354ccf8e9c2f2cb572375bdc188c8 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 24 Oct 2023 15:24:32 +0800 Subject: [PATCH] manager: template import/export --- .../me/weishu/kernelsu/ui/screen/Template.kt | 93 +++++++++++++++++-- .../kernelsu/ui/screen/TemplateEditor.kt | 43 ++------- .../ui/viewmodel/TemplateViewModel.kt | 91 ++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 7 ++ manager/app/src/main/res/values/strings.xml | 7 ++ 5 files changed, 201 insertions(+), 40 deletions(-) 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