manager: template import/export

This commit is contained in:
weishu
2023-10-24 15:24:32 +08:00
parent fae1fd9826
commit 7371eae382
5 changed files with 201 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
package me.weishu.kernelsu.ui.screen package me.weishu.kernelsu.ui.screen
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons 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.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.icons.filled.Sync
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
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
@@ -27,10 +32,17 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -71,18 +83,53 @@ fun AppProfileTemplateScreen(
Scaffold( Scaffold(
topBar = { 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() }, TopBar(onBack = { navigator.popBackStack() },
onSync = { onSync = {
scope.launch { viewModel.fetchTemplates(true) } 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( navigator.navigate(
TemplateEditorScreenDestination( TemplateEditorScreenDestination(
TemplateViewModel.TemplateInfo(), TemplateViewModel.TemplateInfo(),
false false
) )
) )
}) },
icon = { Icon(Icons.Filled.Add, null) },
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
)
}, },
) { innerPadding -> ) { innerPadding ->
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
@@ -146,7 +193,12 @@ private fun TemplateItem(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopBar(onBack: () -> Unit, onSync: () -> Unit, onClickCreate: () -> Unit) { private fun TopBar(
onBack: () -> Unit,
onSync: () -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {}
) {
TopAppBar( TopAppBar(
title = { title = {
Text(stringResource(R.string.settings_profile_template)) Text(stringResource(R.string.settings_profile_template))
@@ -158,10 +210,37 @@ private fun TopBar(onBack: () -> Unit, onSync: () -> Unit, onClickCreate: () ->
}, },
actions = { actions = {
IconButton(onClick = onSync) { 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
})
}
} }
} }
) )

View File

@@ -1,5 +1,6 @@
package me.weishu.kernelsu.ui.screen package me.weishu.kernelsu.ui.screen
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction 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.getAppProfileTemplate
import me.weishu.kernelsu.ui.util.setAppProfileTemplate import me.weishu.kernelsu.ui.util.setAppProfileTemplate
import me.weishu.kernelsu.ui.viewmodel.TemplateViewModel import me.weishu.kernelsu.ui.viewmodel.TemplateViewModel
import me.weishu.kernelsu.ui.viewmodel.toJSON
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -82,6 +85,8 @@ fun TemplateEditorScreen(
"" ""
} }
val titleSummary = "${initialTemplate.id}$author$readOnlyHint" val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
val context = LocalContext.current
TopBar( TopBar(
title = if (isCreation) { title = if (isCreation) {
@@ -102,6 +107,8 @@ fun TemplateEditorScreen(
onSave = { onSave = {
if (saveTemplate(template, isCreation)) { if (saveTemplate(template, isCreation)) {
navigator.navigateBack(result = true) 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 return false
} }
template.apply { val json = template.toJSON()
val json = JSONObject().apply { json.put("local", true)
put("id", id) return setAppProfileTemplate(template.id, json.toString())
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) @OptIn(ExperimentalMaterial3Api::class)

View File

@@ -85,6 +85,53 @@ class TemplateViewModel : ViewModel() {
isRefreshing = false 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() { private fun fetchRemoteTemplates() {
@@ -205,6 +252,50 @@ private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo?
}.getOrNull() }.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") @Suppress("unused")
fun generateTemplates() { fun generateTemplates() {
val templateJson = JSONObject() val templateJson = JSONObject()

View File

@@ -93,4 +93,11 @@
<string name="app_profile_template_view">查看模版</string> <string name="app_profile_template_view">查看模版</string>
<string name="app_profile_template_readonly">只读</string> <string name="app_profile_template_readonly">只读</string>
<string name="app_profile_template_id_exist">模版 id 已存在!</string> <string name="app_profile_template_id_exist">模版 id 已存在!</string>
<string name="app_profile_import_export">导入/导出</string>
<string name="app_profile_import_from_clipboard">从剪切板导入</string>
<string name="app_profile_export_to_clipboard">导出到剪切板</string>
<string name="app_profile_template_export_empty">没有本地模版可以导出!</string>
<string name="app_profile_template_import_success">导入成功!</string>
<string name="app_profile_template_sync">同步在线规则</string>
<string name="app_profile_template_save_failed">模版保存失败!</string>
</resources> </resources>

View File

@@ -95,4 +95,11 @@
<string name="app_profile_template_view">View Template</string> <string name="app_profile_template_view">View Template</string>
<string name="app_profile_template_readonly">readonly</string> <string name="app_profile_template_readonly">readonly</string>
<string name="app_profile_template_id_exist">template id already exists!</string> <string name="app_profile_template_id_exist">template id already exists!</string>
<string name="app_profile_import_export">Import/Export</string>
<string name="app_profile_import_from_clipboard">Import from clipboard</string>
<string name="app_profile_export_to_clipboard">Export to clipboard</string>
<string name="app_profile_template_export_empty">Can not find local template to export!</string>
<string name="app_profile_template_import_success">Imported successfully</string>
<string name="app_profile_template_sync">Sync online templates</string>
<string name="app_profile_template_save_failed">Failed to save template</string>
</resources> </resources>