From 2878ad79ef5848e49b6b50120cd193766b21d435 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 17 Feb 2026 08:30:06 -0600 Subject: [PATCH] refactor(settings): Use LocalConfig for radio configuration state (#4579) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../settings/radio/RadioConfigViewModel.kt | 75 ++++++++++++------- .../component/StatusMessageConfigItemList.kt | 18 ++++- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 5ee64eefa..68fc886a7 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -25,7 +25,6 @@ import android.os.RemoteException import android.util.Base64 import androidx.annotation.RequiresPermission import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -55,7 +54,6 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.getStringResFrom import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.nowMillis -import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.prefs.analytics.AnalyticsPrefs import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs @@ -77,6 +75,7 @@ import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig @@ -94,7 +93,7 @@ data class RadioConfigState( val metadata: DeviceMetadata? = null, val userConfig: User = User(), val channelList: List = emptyList(), - val radioConfig: Config = Config(), + val radioConfig: LocalConfig = LocalConfig(), val moduleConfig: LocalModuleConfig = LocalModuleConfig(), val ringtone: String = "", val cannedMessageMessages: String = "", @@ -178,16 +177,26 @@ constructor( radioConfigRepository.deviceProfileFlow.onEach { _currentDeviceProfile.value = it }.launchIn(viewModelScope) + radioConfigRepository.localConfigFlow + .onEach { lc -> if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(radioConfig = lc) } } + .launchIn(viewModelScope) + + radioConfigRepository.moduleConfigFlow + .onEach { lmc -> + if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(moduleConfig = lmc) } + } + .launchIn(viewModelScope) + serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope) - combine(serviceRepository.connectionState, radioConfigState) { connState, configState -> + combine(serviceRepository.connectionState, radioConfigState) { connState, _ -> _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) } } .launchIn(viewModelScope) nodeRepository.myNodeInfo .onEach { ni -> - _radioConfigState.update { it.copy(isLocal = destNum == null || destNum == ni?.myNodeNum) } + _radioConfigState.update { it.copy(isLocal = (destNum == null) || (destNum == ni?.myNodeNum)) } } .launchIn(viewModelScope) @@ -222,7 +231,7 @@ constructor( private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) = viewModelScope.launch { meshService?.let { service -> - val packetId = service.packetId + val packetId = service.getPacketId() try { requestAction(service, packetId, destNum) requestIds.update { it.apply { add(packetId) } } @@ -275,12 +284,6 @@ constructor( _radioConfigState.update { it.copy(channelList = new) } } - private fun setChannels(channelUrl: String) = viewModelScope.launch { - val new = channelUrl.toUri().toChannelSet() - val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch - updateChannels(new.settings, old.settings) - } - private fun setRemoteChannel(destNum: Int, channel: Channel) = request( destNum, { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) }, @@ -369,7 +372,11 @@ constructor( fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - meshService?.setRingtone(destNum, ringtone) + try { + meshService?.setRingtone(destNum, ringtone) + } catch (ex: RemoteException) { + Logger.e { "Set ringtone error: ${ex.message}" } + } } private fun getRingtone(destNum: Int) = request( @@ -381,7 +388,11 @@ constructor( fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - meshService?.setCannedMessages(destNum, messages) + try { + meshService?.setCannedMessages(destNum, messages) + } catch (ex: RemoteException) { + Logger.e { "Set canned messages error: ${ex.message}" } + } } private fun getCannedMessages(destNum: Int) = request( @@ -416,7 +427,7 @@ constructor( // Clear the service's in-memory node cache first so screens refresh immediately. val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty() meshService?.let { service -> - existingNodeNums.forEach { service.removeByNodenum(service.packetId, it) } + existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) } } nodeRepository.clearNodeDB() } @@ -434,7 +445,7 @@ constructor( // Clear the service's in-memory node cache as well so UI updates immediately. val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty() meshService?.let { service -> - existingNodeNums.forEach { service.removeByNodenum(service.packetId, it) } + existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) } } nodeRepository.clearNodeDB(preserveFavorites) } @@ -451,7 +462,7 @@ constructor( AdminRoute.REBOOT.name -> requestReboot(destNum) AdminRoute.SHUTDOWN.name -> with(radioConfigState.value) { - if (metadata != null && metadata.canShutdown != true) { + if (metadata?.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { requestShutdown(destNum) @@ -511,8 +522,8 @@ constructor( private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) = withContext(Dispatchers.IO) { try { - val publicKeyBytes = securityConfig.public_key?.toByteArray() ?: ByteArray(0) - val privateKeyBytes = securityConfig.private_key?.toByteArray() ?: ByteArray(0) + val publicKeyBytes = securityConfig.public_key.toByteArray() + val privateKeyBytes = securityConfig.private_key.toByteArray() // Convert byte arrays to Base64 strings for human readability in JSON val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP) @@ -609,7 +620,7 @@ constructor( ConfigRoute.CHANNELS -> { getChannel(destNum, 0) - getConfig(destNum, ConfigRoute.LORA.type) + getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) // channel editor is synchronous, so we don't use requestIds as total setResponseStateTotal(maxChannels + 1) } @@ -746,7 +757,7 @@ constructor( state.copy( channelList = state.channelList.toMutableList().apply { - val index = response.index ?: 0 + val index = response.index val settings = response.settings ?: ChannelSettings() // Make sure list is large enough while (size <= index) add(ChannelSettings()) @@ -755,14 +766,14 @@ constructor( ) } incrementCompleted() - val index = response.index ?: 0 + val index = response.index if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel getChannel(destNum, index + 1) } } else { // Received last channel, update total and start channel editor - setResponseStateTotal((response.index ?: 0) + 1) + setResponseStateTotal(response.index + 1) } } @@ -773,7 +784,21 @@ constructor( parsed.get_config_response != null -> { val response = parsed.get_config_response!! - _radioConfigState.update { it.copy(radioConfig = response) } + _radioConfigState.update { state -> + state.copy( + radioConfig = + state.radioConfig.copy( + device = response.device ?: state.radioConfig.device, + position = response.position ?: state.radioConfig.position, + power = response.power ?: state.radioConfig.power, + network = response.network ?: state.radioConfig.network, + display = response.display ?: state.radioConfig.display, + lora = response.lora ?: state.radioConfig.lora, + bluetooth = response.bluetooth ?: state.radioConfig.bluetooth, + security = response.security ?: state.radioConfig.security, + ), + ) + } incrementCompleted() } @@ -818,7 +843,7 @@ constructor( parsed.get_device_connection_status_response != null -> { _radioConfigState.update { - it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response) + it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!) } incrementCompleted() } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt index 75914edeb..57a4ca4cf 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt @@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -42,11 +44,25 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel @Composable fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + val destNode by viewModel.destNode.collectAsStateWithLifecycle() + + // Use the config value if present, otherwise fall back to the node's current status message from telemetry val statusMessageConfig = - state.moduleConfig.statusmessage ?: org.meshtastic.proto.ModuleConfig.StatusMessageConfig() + remember(state.moduleConfig.statusmessage, destNode?.nodeStatus) { + val config = state.moduleConfig.statusmessage ?: org.meshtastic.proto.ModuleConfig.StatusMessageConfig() + val currentStatus = destNode?.nodeStatus ?: "" + if (config.node_status.isBlank() && currentStatus.isNotBlank()) { + config.copy(node_status = currentStatus) + } else { + config + } + } + val formState = rememberConfigState(initialValue = statusMessageConfig) val focusManager = LocalFocusManager.current + LaunchedEffect(statusMessageConfig) { formState.value = statusMessageConfig } + RadioConfigScreenList( title = stringResource(Res.string.status_message), onBack = onBack,