feat: add configs import/export (#628)

This commit is contained in:
Andre K 2023-05-02 07:18:22 -03:00 committed by GitHub
parent 9dc1a45fe6
commit 9e78e516da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 293 additions and 0 deletions

View file

@ -15,6 +15,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.*
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.database.MeshLogRepository
@ -32,6 +33,7 @@ import com.geeksville.mesh.repository.datastore.ModuleConfigRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.positionToMeter
import com.google.protobuf.MessageLite
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -50,7 +52,9 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
@ -424,6 +428,10 @@ class UIViewModel @Inject constructor(
meshService?.setModuleConfig(destNum, config.toByteArray())
}
fun setModuleConfig(config: ModuleConfig) {
setModuleConfig(myNodeNum ?: return, config)
}
/// Convert the channels array to and from [AppOnlyProtos.ChannelSet]
private var _channelSet: AppOnlyProtos.ChannelSet
get() = channels.value.protobuf
@ -651,6 +659,82 @@ class UIViewModel @Inject constructor(
}
}
private val _deviceProfile = MutableStateFlow<DeviceProfile?>(null)
val deviceProfile: StateFlow<DeviceProfile?> = _deviceProfile
fun setDeviceProfile(deviceProfile: DeviceProfile?) {
_deviceProfile.value = deviceProfile
}
fun importProfile(file_uri: Uri) = viewModelScope.launch(Dispatchers.Main) {
withContext(Dispatchers.IO) {
var inputStream: InputStream? = null
try {
inputStream = app.contentResolver.openInputStream(file_uri)
val bytes = inputStream?.readBytes()
val protobuf = DeviceProfile.parseFrom(bytes)
_deviceProfile.value = protobuf
} catch (ex: Exception) {
errormsg("Failed to import radio configs: ${ex.message}")
} finally {
inputStream?.close()
}
}
}
fun exportProfile(file_uri: Uri) = viewModelScope.launch {
val profile = deviceProfile.value ?: return@launch
writeToUri(file_uri, profile)
_deviceProfile.value = null
}
private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
message.writeTo(outputStream)
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
fun installProfile(protobuf: DeviceProfile) = with(protobuf) {
_deviceProfile.value = null
// meshService?.beginEditSettings()
if (hasLongName() || hasShortName()) ourNodeInfo.value?.user?.let {
val user = it.copy(
longName = if (hasLongName()) longName else it.longName,
shortName = if (hasShortName()) shortName else it.shortName
)
setOwner(user)
}
if (hasChannelUrl()) {
setChannels(ChannelSet(Uri.parse(channelUrl)))
}
if (hasConfig()) {
setConfig(config { device = config.device })
setConfig(config { position = config.position })
setConfig(config { power = config.power })
setConfig(config { network = config.network })
setConfig(config { display = config.display })
setConfig(config { lora = config.lora })
setConfig(config { bluetooth = config.bluetooth })
}
if (hasModuleConfig()) moduleConfig.let {
setModuleConfig(moduleConfig { mqtt = it.mqtt })
setModuleConfig(moduleConfig { serial = it.serial })
setModuleConfig(moduleConfig { externalNotification = it.externalNotification })
setModuleConfig(moduleConfig { storeForward = it.storeForward })
setModuleConfig(moduleConfig { rangeTest = it.rangeTest })
setModuleConfig(moduleConfig { telemetry = it.telemetry })
setModuleConfig(moduleConfig { cannedMessage = it.cannedMessage })
setModuleConfig(moduleConfig { audio = it.audio })
setModuleConfig(moduleConfig { remoteHardware = it.remoteHardware })
}
// meshService?.commitEditSettings()
}
fun parseUrl(url: String, map: MapView) {
viewModelScope.launch(Dispatchers.IO) {

View file

@ -1,9 +1,14 @@
package com.geeksville.mesh.ui
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
@ -57,6 +62,7 @@ import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.channel
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.config
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.service.MeshService
@ -68,6 +74,7 @@ import com.geeksville.mesh.ui.components.config.CannedMessageConfigItemList
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
import com.geeksville.mesh.ui.components.config.DeviceConfigItemList
import com.geeksville.mesh.ui.components.config.DisplayConfigItemList
import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog
import com.geeksville.mesh.ui.components.config.ExternalNotificationConfigItemList
import com.geeksville.mesh.ui.components.config.LoRaConfigItemList
import com.geeksville.mesh.ui.components.config.MQTTConfigItemList
@ -148,11 +155,76 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
var cannedMessageMessages by remember { mutableStateOf("") }
val configResponse by viewModel.packetResponse.collectAsStateWithLifecycle()
val deviceProfile by viewModel.deviceProfile.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
val importConfigLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { file_uri -> viewModel.importProfile(file_uri) }
}
}
val exportConfigLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { file_uri -> viewModel.exportProfile(file_uri) }
}
}
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
if (showEditDeviceProfileDialog) EditDeviceProfileDialog(
title = "Export configuration",
deviceProfile = with(viewModel) {
deviceProfile {
ourNodeInfo.value?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.value.getChannelUrl().toString()
config = localConfig.value
this.moduleConfig = module
}
},
onAddClick = {
isWaiting = false
viewModel.setDeviceProfile(it)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "${destNum.toUInt()}.cfg")
}
exportConfigLauncher.launch(intent)
showEditDeviceProfileDialog = false
},
onDismissRequest = {
isWaiting = false
showEditDeviceProfileDialog = false
viewModel.setDeviceProfile(null)
}
)
if (isWaiting && deviceProfile != null) {
EditDeviceProfileDialog(
title = "Import configuration",
deviceProfile = deviceProfile ?: return,
onAddClick = {
isWaiting = false
viewModel.installProfile(it)
},
onDismissRequest = {
isWaiting = false
viewModel.setDeviceProfile(null)
}
)
}
if (isWaiting) LaunchedEffect(configResponse) {
val data = configResponse?.meshPacket?.decoded
if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) {
viewModel.clearPacketResponse()
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
when (parsed.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
@ -216,6 +288,7 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
composable("home") {
RadioSettingsScreen(
enabled = connected && !isWaiting,
isLocal = destNum == viewModel.myNodeNum,
headerText = node.user?.longName ?: stringResource(R.string.unknown_username),
onRouteClick = { configType ->
isWaiting = true
@ -226,6 +299,15 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
channelList.clear()
viewModel.getChannel(destNum, 0)
}
"IMPORT" -> {
viewModel.setDeviceProfile(null)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
}
importConfigLauncher.launch(intent)
}
"EXPORT" -> { showEditDeviceProfileDialog = true }
is ConfigType -> {
viewModel.getConfig(destNum, configType.number)
}
@ -544,10 +626,16 @@ fun NavCard(
}
}
@Composable
fun NavCard(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) {
NavCard(title = stringResource(title), enabled = enabled, onClick = onClick)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RadioSettingsScreen(
enabled: Boolean = true,
isLocal: Boolean = true,
headerText: String = "longName",
onRouteClick: (Any) -> Unit = {},
) {
@ -567,6 +655,12 @@ fun RadioSettingsScreen(
items(ModuleDest.values()) { modules ->
NavCard(modules.title, enabled = enabled) { onRouteClick(modules.config) }
}
if (isLocal) {
item { PreferenceCategory("Import / Export") }
item { NavCard("Import configuration", enabled = enabled) { onRouteClick("IMPORT") } }
item { NavCard("Export configuration", enabled = enabled) { onRouteClick("EXPORT") } }
}
}
}

View file

@ -0,0 +1,115 @@
package com.geeksville.mesh.ui.components.config
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ClientOnlyProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.ui.components.SwitchPreference
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
@Composable
fun EditDeviceProfileDialog(
title: String,
deviceProfile: ClientOnlyProtos.DeviceProfile,
onAddClick: (ClientOnlyProtos.DeviceProfile) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var longNameInput by remember { mutableStateOf(deviceProfile.hasLongName()) }
var shortNameInput by remember { mutableStateOf(deviceProfile.hasShortName()) }
var channelUrlInput by remember { mutableStateOf(deviceProfile.hasChannelUrl()) }
var configInput by remember { mutableStateOf(deviceProfile.hasConfig()) }
var moduleConfigInput by remember { mutableStateOf(deviceProfile.hasModuleConfig()) }
AlertDialog(
title = { Text(title) },
onDismissRequest = onDismissRequest,
text = {
AppCompatTheme {
Column(modifier.fillMaxWidth()) {
SwitchPreference(title = "longName",
checked = longNameInput,
enabled = deviceProfile.hasLongName(),
onCheckedChange = { longNameInput = it }
)
SwitchPreference(title = "shortName",
checked = shortNameInput,
enabled = deviceProfile.hasShortName(),
onCheckedChange = { shortNameInput = it }
)
SwitchPreference(title = "channelUrl",
checked = channelUrlInput,
enabled = deviceProfile.hasChannelUrl(),
onCheckedChange = { channelUrlInput = it }
)
SwitchPreference(title = "config",
checked = configInput,
enabled = deviceProfile.hasConfig(),
onCheckedChange = { configInput = it }
)
SwitchPreference(title = "moduleConfig",
checked = moduleConfigInput,
enabled = deviceProfile.hasModuleConfig(),
onCheckedChange = { moduleConfigInput = it }
)
}
}
},
buttons = {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
modifier = modifier
.fillMaxWidth()
.padding(start = 24.dp)
.weight(1f),
onClick = onDismissRequest
) { Text(stringResource(R.string.cancel)) }
Button(
modifier = modifier
.fillMaxWidth()
.padding(end = 24.dp)
.weight(1f),
onClick = {
onAddClick(deviceProfile {
if (longNameInput) longName = deviceProfile.longName
if (shortNameInput) shortName = deviceProfile.shortName
if (channelUrlInput) channelUrl = deviceProfile.channelUrl
if (configInput) config = deviceProfile.config
if (moduleConfigInput) moduleConfig = deviceProfile.moduleConfig
})
},
) { Text(stringResource(R.string.save)) }
}
}
)
}
@Preview(showBackground = true)
@Composable
fun EditDeviceProfileDialogPreview() {
EditDeviceProfileDialog(
title = "Export configuration",
deviceProfile = deviceProfile { },
onAddClick = { },
onDismissRequest = { },
)
}