From a5cd2d6bbcc86a4d36de234c94ef04a086ca29c6 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:25:47 -0400 Subject: [PATCH] Decouple `ChannelScreen` from `UIViewModel` (#3295) --- .../java/com/geeksville/mesh/model/UIState.kt | 17 --- .../mesh/navigation/ChannelsNavigation.kt | 4 +- .../mesh/navigation/NodesNavigation.kt | 53 +++----- .../main/java/com/geeksville/mesh/ui/Main.kt | 4 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 20 ++- .../mesh/ui/sharing/ChannelViewModel.kt | 124 ++++++++++++++++++ 6 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.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 e5a482bb4..3ceeb7d29 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -58,7 +58,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.DeviceHardwareRepository import org.meshtastic.core.data.repository.FirmwareReleaseRepository import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository @@ -158,7 +157,6 @@ constructor( private val serviceRepository: ServiceRepository, radioInterfaceService: RadioInterfaceService, meshLogRepository: MeshLogRepository, - private val deviceHardwareRepository: DeviceHardwareRepository, private val quickChatActionRepository: QuickChatActionRepository, firmwareReleaseRepository: FirmwareReleaseRepository, private val uiPreferencesDataSource: UiPreferencesDataSource, @@ -307,11 +305,6 @@ constructor( val connectionState get() = serviceRepository.connectionState - val isConnectedStateFlow = - serviceRepository.connectionState - .map { it.isConnected() } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val _requestChannelSet = MutableStateFlow(null) val requestChannelSet: StateFlow get() = _requestChannelSet @@ -329,22 +322,12 @@ constructor( _requestChannelSet.value = null } - var txEnabled: Boolean - get() = config.lora.txEnabled - set(value) { - updateLoraConfig { it.copy { txEnabled = value } } - } - var region: Config.LoRaConfig.RegionCode get() = config.lora.region set(value) { updateLoraConfig { it.copy { region = value } } } - // managed mode disables all access to configuration - val isManaged: Boolean - get() = config.device.isManaged || config.security.isManaged - override fun onCleared() { super.onCleared() Timber.d("ViewModel cleared") diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt index cc699b81e..04e14dbbc 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt @@ -24,7 +24,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.navDeepLink import androidx.navigation.navigation -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.settings.radio.components.ChannelConfigScreen import com.geeksville.mesh.ui.settings.radio.components.LoRaConfigScreen import com.geeksville.mesh.ui.sharing.ChannelScreen @@ -32,14 +31,13 @@ import org.meshtastic.core.navigation.ChannelsRoutes import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI /** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */ -fun NavGraphBuilder.channelsGraph(navController: NavHostController, uiViewModel: UIViewModel) { +fun NavGraphBuilder.channelsGraph(navController: NavHostController) { navigation(startDestination = ChannelsRoutes.Channels) { composable( deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/channels")), ) { backStackEntry -> val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) } ChannelScreen( - viewModel = uiViewModel, radioConfigViewModel = hiltViewModel(parentEntry), onNavigate = { route -> navController.navigate(route) }, ) diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index c5a649617..fb2c83c8f 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -40,7 +40,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.navDeepLink import com.geeksville.mesh.model.MetricsViewModel -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen @@ -59,19 +58,19 @@ import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.Route import org.meshtastic.core.strings.R -fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) { +fun NavGraphBuilder.nodesGraph(navController: NavHostController) { navigation(startDestination = NodesRoutes.Nodes) { composable( deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/nodes")), ) { NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }) } - nodeDetailGraph(navController, uiViewModel) + nodeDetailGraph(navController) } } @Suppress("LongMethod") -fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) { +fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) { navigation(startDestination = NodesRoutes.NodeDetail()) { composable( deepLinks = @@ -96,63 +95,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewMode is NodeDetailRoutes.DeviceMetrics -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.NodeMap -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.PositionLog -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.EnvironmentMetrics -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.SignalMetrics -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.PowerMetrics -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.TracerouteLog -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.HostMetricsLog -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) is NodeDetailRoutes.PaxMetrics -> addNodeDetailScreenComposable( navController, - uiViewModel, entry, entry.screenComposable, ) @@ -174,21 +164,15 @@ fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { * * @param R The type of the [Route] object, must be serializable. * @param navController The [NavHostController] for navigation. - * @param uiViewModel The shared [UIViewModel], passed to the [screenContent]. * @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route. * @param screenContent A lambda that defines the composable content for the screen. It receives the shared - * [MetricsViewModel] and the [UIViewModel]. + * [MetricsViewModel]. */ private inline fun NavGraphBuilder.addNodeDetailScreenComposable( navController: NavHostController, - uiViewModel: UIViewModel, routeInfo: NodeDetailRoute, crossinline screenContent: - @Composable ( - navController: NavHostController, - metricsViewModel: MetricsViewModel, - passedUiViewModel: UIViewModel, - ) -> Unit, + @Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit, ) { composable( deepLinks = @@ -200,7 +184,7 @@ private inline fun NavGraphBuilder.addNodeDetailScreenCompos val parentGraphBackStackEntry = remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } val metricsViewModel = hiltViewModel(parentGraphBackStackEntry) - screenContent(navController, metricsViewModel, uiViewModel) + screenContent(navController, metricsViewModel) } } @@ -208,65 +192,60 @@ enum class NodeDetailRoute( @StringRes val title: Int, val route: Route, val icon: ImageVector?, - val screenComposable: - @Composable ( - navController: NavHostController, - metricsViewModel: MetricsViewModel, - uiViewModel: UIViewModel, - ) -> Unit, + val screenComposable: @Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit, ) { DEVICE( R.string.device, NodeDetailRoutes.DeviceMetrics, Icons.Default.Router, - { _, metricsVM, _ -> DeviceMetricsScreen(metricsVM) }, + { _, metricsVM -> DeviceMetricsScreen(metricsVM) }, ), NODE_MAP( R.string.node_map, NodeDetailRoutes.NodeMap, Icons.Default.LocationOn, - { navController, metricsVM, _ -> NodeMapScreen(navController, metricsVM) }, + { navController, metricsVM -> NodeMapScreen(navController, metricsVM) }, ), POSITION_LOG( R.string.position_log, NodeDetailRoutes.PositionLog, Icons.Default.LocationOn, - { _, metricsVM, _ -> PositionLogScreen(metricsVM) }, + { _, metricsVM -> PositionLogScreen(metricsVM) }, ), ENVIRONMENT( R.string.environment, NodeDetailRoutes.EnvironmentMetrics, Icons.Default.LightMode, - { _, metricsVM, _ -> EnvironmentMetricsScreen(metricsVM) }, + { _, metricsVM -> EnvironmentMetricsScreen(metricsVM) }, ), SIGNAL( R.string.signal, NodeDetailRoutes.SignalMetrics, Icons.Default.CellTower, - { _, metricsVM, _ -> SignalMetricsScreen(metricsVM) }, + { _, metricsVM -> SignalMetricsScreen(metricsVM) }, ), TRACEROUTE( R.string.traceroute, NodeDetailRoutes.TracerouteLog, Icons.Default.PermScanWifi, - { _, metricsVM, _ -> TracerouteLogScreen(viewModel = metricsVM) }, + { _, metricsVM -> TracerouteLogScreen(viewModel = metricsVM) }, ), POWER( R.string.power, NodeDetailRoutes.PowerMetrics, Icons.Default.Power, - { _, metricsVM, _ -> PowerMetricsScreen(metricsVM) }, + { _, metricsVM -> PowerMetricsScreen(metricsVM) }, ), HOST( R.string.host, NodeDetailRoutes.HostMetricsLog, Icons.Default.Memory, - { _, metricsVM, _ -> HostMetricsLogScreen(metricsVM) }, + { _, metricsVM -> HostMetricsLogScreen(metricsVM) }, ), PAX( R.string.pax, NodeDetailRoutes.PaxMetrics, Icons.Default.People, - { _, metricsVM, _ -> PaxMetricsScreen(metricsVM) }, + { _, metricsVM -> PaxMetricsScreen(metricsVM) }, ), } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 31a5b70d0..0f79c842d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -390,9 +390,9 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(), ) { contactsGraph(navController) - nodesGraph(navController, uiViewModel = uIViewModel) + nodesGraph(navController) mapGraph(navController) - channelsGraph(navController, uiViewModel = uIViewModel) + channelsGraph(navController) connectionsGraph(navController) settingsGraph(navController) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 5bcb40402..38d2f08e6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -21,6 +21,7 @@ import android.Manifest import android.content.ClipData import android.net.Uri import android.os.RemoteException +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -74,6 +75,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -92,7 +94,6 @@ import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.MeshUtilApplication.Companion.analytics import com.geeksville.mesh.channelSet import com.geeksville.mesh.copy -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.ConfigRoute import com.geeksville.mesh.navigation.getNavRouteFrom import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel @@ -124,7 +125,7 @@ import timber.log.Timber @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ChannelScreen( - viewModel: UIViewModel = hiltViewModel(), + viewModel: ChannelViewModel = hiltViewModel(), radioConfigViewModel: RadioConfigViewModel = hiltViewModel(), onNavigate: (Route) -> Unit, ) { @@ -175,10 +176,13 @@ fun ChannelScreen( settings.addAll(result) } + val context = LocalContext.current val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> if (result.contents != null) { - viewModel.requestChannelUrl(result.contents.toUri()) + viewModel.requestChannelUrl(result.contents.toUri()) { + Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show() + } } } @@ -210,12 +214,12 @@ fun ChannelScreen( viewModel.setChannels(newChannelSet) // Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc) } catch (ex: RemoteException) { - Timber.e("ignoring channel problem", ex) + Timber.e(ex, "ignoring channel problem") channelSet = channels // Throw away user edits // Tell the user to try again - viewModel.showSnackBar(R.string.cant_change_no_radio) + Toast.makeText(context, R.string.cant_change_no_radio, Toast.LENGTH_SHORT).show() } } @@ -282,7 +286,11 @@ fun ChannelScreen( EditChannelUrl( enabled = enabled, channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState), - onConfirm = viewModel::requestChannelUrl, + onConfirm = { + viewModel.requestChannelUrl(it) { + Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show() + } + }, ) } item { diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt new file mode 100644 index 000000000..01f24b7d2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.sharing + +import android.net.Uri +import android.os.RemoteException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.AppOnlyProtos +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ConfigProtos.Config +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.channelSet +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.getChannelList +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.model.util.toChannelSet +import org.meshtastic.core.service.ServiceRepository +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ChannelViewModel +@Inject +constructor( + private val serviceRepository: ServiceRepository, + private val radioConfigRepository: RadioConfigRepository, +) : ViewModel() { + + val connectionState = serviceRepository.connectionState + + val localConfig = + radioConfigRepository.localConfigFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + LocalConfig.getDefaultInstance(), + ) + + val channels = + radioConfigRepository.channelSetFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + channelSet {}, + ) + + // managed mode disables all access to configuration + val isManaged: Boolean + get() = localConfig.value.device.isManaged || localConfig.value.security.isManaged + + var txEnabled: Boolean + get() = localConfig.value.lora.txEnabled + set(value) { + updateLoraConfig { it.copy { txEnabled = value } } + } + + var region: Config.LoRaConfig.RegionCode + get() = localConfig.value.lora.region + set(value) { + updateLoraConfig { it.copy { region = value } } + } + + private val _requestChannelSet = MutableStateFlow(null) + val requestChannelSet: StateFlow + get() = _requestChannelSet + + fun requestChannelUrl(url: Uri, onError: () -> Unit) = runCatching { _requestChannelSet.value = url.toChannelSet() } + .onFailure { ex -> + Timber.e(ex, "Channel url error") + onError() + } + + /** Set the radio config (also updates our saved copy in preferences). */ + fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch { + getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(channelSet.settingsList) + + val newConfig = config { lora = channelSet.loraConfig } + if (localConfig.value.lora != newConfig.lora) setConfig(newConfig) + } + + fun setChannel(channel: ChannelProtos.Channel) { + try { + serviceRepository.meshService?.setChannel(channel.toByteArray()) + } catch (ex: RemoteException) { + Timber.e(ex, "Set channel error") + } + } + + // Set the radio config (also updates our saved copy in preferences) + fun setConfig(config: Config) { + try { + serviceRepository.meshService?.setConfig(config.toByteArray()) + } catch (ex: RemoteException) { + Timber.e(ex, "Set config error") + } + } + + private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { + val data = body(localConfig.value.lora) + setConfig(config { lora = data }) + } +}