From 3d94391bb1b6411e0100d6346018f963102ed7f6 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:24:37 -0400 Subject: [PATCH] Decouple `NodeDetail` from `UiViewModel` (#3212) --- .../java/com/geeksville/mesh/model/UIState.kt | 89 ------------ .../mesh/navigation/NodesNavigation.kt | 1 - .../com/geeksville/mesh/ui/node/NodeDetail.kt | 30 ++-- .../mesh/ui/node/NodeDetailViewModel.kt | 133 ++++++++++++++++++ 4 files changed, 148 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailViewModel.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 86a3dad2a..5e0092b07 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -52,9 +52,7 @@ import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.radio.MeshActivity import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.service.MeshServiceNotifications -import com.geeksville.mesh.service.ServiceAction import com.geeksville.mesh.service.ServiceRepository -import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.util.safeNumber import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -80,9 +78,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.getShortDate -import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.strings.R import javax.inject.Inject @@ -172,7 +168,6 @@ constructor( private val packetRepository: PacketRepository, private val quickChatActionRepository: QuickChatActionRepository, firmwareReleaseRepository: FirmwareReleaseRepository, - private val uiPrefs: UiPrefs, private val uiPreferencesDataSource: UiPreferencesDataSource, private val meshServiceNotifications: MeshServiceNotifications, ) : ViewModel(), @@ -180,9 +175,6 @@ constructor( val theme: StateFlow = uiPreferencesDataSource.theme - private val _lastTraceRouteTime = MutableStateFlow(null) - val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() - val firmwareVersion = myNodeInfo.mapNotNull { nodeInfo -> nodeInfo?.firmwareVersion } val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition } @@ -428,45 +420,6 @@ constructor( _sharedContactRequested.value = sharedContact } - fun requestTraceroute(destNum: Int) { - info("Requesting traceroute for '$destNum'") - try { - val packetId = meshService?.packetId ?: return - meshService?.requestTraceroute(packetId, destNum) - } catch (ex: RemoteException) { - errormsg("Request traceroute error: ${ex.message}") - } - } - - fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) { - info("Removing node '$nodeNum'") - try { - val packetId = meshService?.packetId ?: return@launch - meshService?.removeByNodenum(packetId, nodeNum) - nodeDB.deleteNode(nodeNum) - } catch (ex: RemoteException) { - errormsg("Remove node error: ${ex.message}") - } - } - - fun requestUserInfo(destNum: Int) { - info("Requesting UserInfo for '$destNum'") - try { - meshService?.requestUserInfo(destNum) - } catch (ex: RemoteException) { - errormsg("Request NodeInfo error: ${ex.message}") - } - } - - fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { - info("Requesting position for '$destNum'") - try { - meshService?.requestPosition(destNum, position) - } catch (ex: RemoteException) { - errormsg("Request position error: ${ex.message}") - } - } - fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } @@ -513,48 +466,6 @@ constructor( updateLoraConfig { it.copy { region = value } } } - fun favoriteNode(node: Node) = viewModelScope.launch { - try { - serviceRepository.onServiceAction(ServiceAction.Favorite(node)) - } catch (ex: RemoteException) { - errormsg("Favorite node error:", ex) - } - } - - fun ignoreNode(node: Node) = viewModelScope.launch { - try { - serviceRepository.onServiceAction(ServiceAction.Ignore(node)) - } catch (ex: RemoteException) { - errormsg("Ignore node error:", ex) - } - } - - fun handleNodeMenuAction(action: NodeMenuAction) { - when (action) { - is NodeMenuAction.Remove -> removeNode(action.node.num) - is NodeMenuAction.Ignore -> ignoreNode(action.node) - is NodeMenuAction.Favorite -> favoriteNode(action.node) - is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num) - is NodeMenuAction.RequestPosition -> requestPosition(action.node.num) - is NodeMenuAction.TraceRoute -> { - requestTraceroute(action.node.num) - _lastTraceRouteTime.value = System.currentTimeMillis() - } - - else -> {} - } - } - - fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) { - try { - nodeDB.setNodeNotes(nodeNum, notes) - } catch (ex: java.io.IOException) { - errormsg("Set node notes IO error: ${ex.message}") - } catch (ex: java.sql.SQLException) { - errormsg("Set node notes SQL error: ${ex.message}") - } - } - // managed mode disables all access to configuration val isManaged: Boolean get() = config.device.isManaged || config.security.isManaged 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 5ea9aa797..1db6f5224 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -87,7 +87,6 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewMode val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } NodeDetailScreen( - uiViewModel = uiViewModel, navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, onNavigate = { navController.navigate(it) }, onNavigateUp = { navController.navigateUp() }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt index 769a1280f..8640534bb 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt @@ -131,7 +131,6 @@ import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.model.MetricsState import com.geeksville.mesh.model.MetricsViewModel -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.ServiceAction import com.geeksville.mesh.ui.common.components.MainAppBar import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider @@ -188,16 +187,16 @@ private data class DrawableMetricInfo( fun NodeDetailScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), - uiViewModel: UIViewModel = hiltViewModel(), + nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(), navigateToMessages: (String) -> Unit = {}, onNavigate: (Route) -> Unit = {}, onNavigateUp: () -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() - val lastTracerouteTime by uiViewModel.lastTraceRouteTime.collectAsStateWithLifecycle() - val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false) + val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle() + val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val connectionState by nodeDetailViewModel.connectionState.collectAsStateWithLifecycle() val availableLogs by remember(state, environmentState) { @@ -226,7 +225,7 @@ fun NodeDetailScreen( MainAppBar( title = node?.user?.longName ?: "", ourNode = ourNode, - isConnected = isConnected, + isConnected = connectionState.isConnected(), showNodeChip = false, canNavigateUp = true, onNavigateUp = onNavigateUp, @@ -243,19 +242,20 @@ fun NodeDetailScreen( metricsState = state, lastTracerouteTime = lastTracerouteTime, availableLogs = availableLogs, - uiViewModel = uiViewModel, onAction = { action -> handleNodeAction( action = action, - uiViewModel = uiViewModel, + ourNode = ourNode, node = node, navigateToMessages = navigateToMessages, onNavigateUp = onNavigateUp, onNavigate = onNavigate, viewModel = viewModel, + handleNodeMenuAction = { nodeDetailViewModel.handleNodeMenuAction(it) }, ) }, modifier = modifier.padding(paddingValues), + onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) }, ) } else { Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { @@ -267,12 +267,13 @@ fun NodeDetailScreen( private fun handleNodeAction( action: NodeDetailAction, - uiViewModel: UIViewModel, + ourNode: Node?, node: Node, navigateToMessages: (String) -> Unit, onNavigateUp: () -> Unit, onNavigate: (Route) -> Unit, viewModel: MetricsViewModel, + handleNodeMenuAction: (NodeMenuAction) -> Unit, ) { when (action) { is NodeDetailAction.Navigate -> onNavigate(action.route) @@ -280,17 +281,17 @@ private fun handleNodeAction( is NodeDetailAction.HandleNodeMenuAction -> { when (val menuAction = action.action) { is NodeMenuAction.DirectMessage -> { - val hasPKC = uiViewModel.ourNodeInfo.value?.hasPKC == true + val hasPKC = ourNode?.hasPKC == true val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel navigateToMessages("$channel${node.user.id}") } is NodeMenuAction.Remove -> { - uiViewModel.handleNodeMenuAction(menuAction) + handleNodeMenuAction(menuAction) onNavigateUp() } - else -> uiViewModel.handleNodeMenuAction(menuAction) + else -> handleNodeMenuAction(menuAction) } } @@ -337,11 +338,10 @@ private fun NodeDetailContent( metricsState: MetricsState, lastTracerouteTime: Long?, availableLogs: Set, - uiViewModel: UIViewModel, onAction: (NodeDetailAction) -> Unit, modifier: Modifier = Modifier, + onSaveNotes: (nodeNum: Int, notes: String) -> Unit, ) { - uiViewModel.setTitle(node.user.longName) var showShareDialog by remember { mutableStateOf(false) } if (showShareDialog) { SharedContactDialog(node) { showShareDialog = false } @@ -361,7 +361,7 @@ private fun NodeDetailContent( }, modifier = modifier, availableLogs = availableLogs, - onSaveNotes = { num, notes -> uiViewModel.setNodeNotes(num, notes) }, + onSaveNotes = onSaveNotes, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailViewModel.kt new file mode 100644 index 000000000..dd566467d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailViewModel.kt @@ -0,0 +1,133 @@ +/* + * 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.node + +import android.os.RemoteException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.database.NodeRepository +import com.geeksville.mesh.service.ServiceAction +import com.geeksville.mesh.service.ServiceRepository +import com.geeksville.mesh.ui.node.components.NodeMenuAction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Position +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class NodeDetailViewModel +@Inject +constructor( + private val nodeRepository: NodeRepository, + private val serviceRepository: ServiceRepository, +) : ViewModel() { + + val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo + + val connectionState = serviceRepository.connectionState + + private val _lastTraceRouteTime = MutableStateFlow(null) + val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() + + fun handleNodeMenuAction(action: NodeMenuAction) { + when (action) { + is NodeMenuAction.Remove -> removeNode(action.node.num) + is NodeMenuAction.Ignore -> ignoreNode(action.node) + is NodeMenuAction.Favorite -> favoriteNode(action.node) + is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num) + is NodeMenuAction.RequestPosition -> requestPosition(action.node.num) + is NodeMenuAction.TraceRoute -> { + requestTraceroute(action.node.num) + _lastTraceRouteTime.value = System.currentTimeMillis() + } + + else -> {} + } + } + + fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) { + try { + nodeRepository.setNodeNotes(nodeNum, notes) + } catch (ex: java.io.IOException) { + Timber.e("Set node notes IO error: ${ex.message}") + } catch (ex: java.sql.SQLException) { + Timber.e("Set node notes SQL error: ${ex.message}") + } + } + + private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) { + Timber.i("Removing node '$nodeNum'") + try { + val packetId = serviceRepository.meshService?.packetId ?: return@launch + serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) + nodeRepository.deleteNode(nodeNum) + } catch (ex: RemoteException) { + Timber.e("Remove node error: ${ex.message}") + } + } + + private fun ignoreNode(node: Node) = viewModelScope.launch { + try { + serviceRepository.onServiceAction(ServiceAction.Ignore(node)) + } catch (ex: RemoteException) { + Timber.e(ex, "Ignore node error") + } + } + + private fun favoriteNode(node: Node) = viewModelScope.launch { + try { + serviceRepository.onServiceAction(ServiceAction.Favorite(node)) + } catch (ex: RemoteException) { + Timber.e(ex, "Favorite node error") + } + } + + private fun requestUserInfo(destNum: Int) { + Timber.i("Requesting UserInfo for '$destNum'") + try { + serviceRepository.meshService?.requestUserInfo(destNum) + } catch (ex: RemoteException) { + Timber.e("Request NodeInfo error: ${ex.message}") + } + } + + private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { + Timber.i("Requesting position for '$destNum'") + try { + serviceRepository.meshService?.requestPosition(destNum, position) + } catch (ex: RemoteException) { + Timber.e("Request position error: ${ex.message}") + } + } + + private fun requestTraceroute(destNum: Int) { + Timber.i("Requesting traceroute for '$destNum'") + try { + val packetId = serviceRepository.meshService?.packetId ?: return + serviceRepository.meshService?.requestTraceroute(packetId, destNum) + } catch (ex: RemoteException) { + Timber.e("Request traceroute error: ${ex.message}") + } + } +}