mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: add configs import/export (#628)
This commit is contained in:
parent
9dc1a45fe6
commit
9e78e516da
3 changed files with 293 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue