manager: template import/export
This commit is contained in:
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user