From 9e78e516dab5d31698a6027ad72745271cce746e Mon Sep 17 00:00:00 2001 From: Andre K Date: Tue, 2 May 2023 07:18:22 -0300 Subject: [PATCH] feat: add configs import/export (#628) --- .../java/com/geeksville/mesh/model/UIState.kt | 84 +++++++++++++ .../mesh/ui/DeviceSettingsFragment.kt | 94 ++++++++++++++ .../config/EditDeviceProfileDialog.kt | 115 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 9c6df57cf..4ecf9b8a9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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(null) + val deviceProfile: StateFlow = _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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt index 1659009db..b8340f0f6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -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") } } + } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt new file mode 100644 index 000000000..edf15c3e8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt @@ -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 = { }, + ) +}