manager: template import/export
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
IconButton(onClick = onClickCreate) {
|
||||
Icon(Icons.Filled.Create, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -93,4 +93,11 @@
|
||||
<string name="app_profile_template_view">查看模版</string>
|
||||
<string name="app_profile_template_readonly">只读</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>
|
||||
@@ -95,4 +95,11 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user