feat(node): smoother remote-admin UX with per-node session tracking (#5217)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
James Rich
2026-04-22 14:21:04 -05:00
committed by GitHub
parent 15dce97bd5
commit 24f19db79a
30 changed files with 931 additions and 58 deletions

View File

@@ -16,27 +16,39 @@
*/
package org.meshtastic.feature.node.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.establishing_session
import org.meshtastic.core.resources.firmware
import org.meshtastic.core.resources.firmware_edition
import org.meshtastic.core.resources.installed_firmware_version
import org.meshtastic.core.resources.latest_alpha_firmware
import org.meshtastic.core.resources.latest_stable_firmware
import org.meshtastic.core.resources.refresh_metadata
import org.meshtastic.core.resources.remote_admin
import org.meshtastic.core.resources.request_metadata
import org.meshtastic.core.resources.session_active
import org.meshtastic.core.resources.session_refresh_required
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.ForkLeft
import org.meshtastic.core.ui.icon.Icecream
@@ -57,35 +69,113 @@ fun AdministrationSection(
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
sessionStatus: SessionStatus,
isEnsuringSession: Boolean,
modifier: Modifier = Modifier,
) {
SectionCard(title = Res.string.administration, modifier = modifier) {
Column {
ListItem(
text = stringResource(Res.string.request_metadata),
leadingIcon = MeshtasticIcons.Memory,
trailingIcon = null,
onClick = {
onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num)))
},
)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp)) {
SectionCard(title = Res.string.administration) {
Column {
// Local nodes don't need a session — they short-circuit straight to the settings screen.
if (metricsState.isLocal) {
ListItem(
text = stringResource(Res.string.remote_admin),
leadingIcon = MeshtasticIcons.Settings,
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(node.num)) },
)
} else {
RemoteAdminListItem(
nodeNum = node.num,
sessionStatus = sessionStatus,
isEnsuringSession = isEnsuringSession,
onAction = onAction,
)
SectionDivider()
SectionDivider()
ListItem(
text = stringResource(Res.string.remote_admin),
leadingIcon = MeshtasticIcons.Settings,
enabled = metricsState.isLocal || node.metadata != null,
) {
onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num)))
ListItem(
text = stringResource(Res.string.refresh_metadata),
leadingIcon = MeshtasticIcons.Memory,
trailingIcon = null,
enabled = !isEnsuringSession,
onClick = { onAction(NodeDetailAction.RefreshMetadata(node.num)) },
)
}
}
}
}
val firmwareVersion = node.metadata?.firmware_version
val firmwareEdition = metricsState.firmwareEdition
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
val firmwareVersion = node.metadata?.firmware_version
val firmwareEdition = metricsState.firmwareEdition
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
}
}
}
/**
* Single primary affordance for opening the remote-admin screen. Replaces the prior two-row, no-feedback flow that
* required the user to know they had to tap "Metadata" first to populate `node.metadata` before "Remote Administration"
* un-greyed out. The session passkey freshness — not the metadata insert — is the real gate (see
* `firmware/src/modules/AdminModule.cpp:1460-1481`), and is now reflected via an [AssistChip] + inline progress.
*/
@Composable
private fun RemoteAdminListItem(
nodeNum: Int,
sessionStatus: SessionStatus,
isEnsuringSession: Boolean,
onAction: (NodeDetailAction) -> Unit,
) {
val supportingTextRes =
when (sessionStatus) {
SessionStatus.NoSession -> Res.string.connect_radio_for_remote_admin
is SessionStatus.Active -> null
is SessionStatus.Stale -> Res.string.session_refresh_required
}
val chipLabelRes =
when (sessionStatus) {
SessionStatus.NoSession -> null
is SessionStatus.Active -> Res.string.session_active
is SessionStatus.Stale -> Res.string.session_refresh_required
}
Column {
BasicListItem(
text = stringResource(Res.string.remote_admin),
leadingIcon = MeshtasticIcons.Settings,
supportingText = supportingTextRes?.let { stringResource(it) },
enabled = !isEnsuringSession,
trailingContent =
chipLabelRes?.let { res ->
{
AssistChip(
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
label = { androidx.compose.material3.Text(stringResource(res)) },
enabled = !isEnsuringSession,
colors =
if (sessionStatus is SessionStatus.Active) {
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
AssistChipDefaults.assistChipColors()
},
)
}
},
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
)
AnimatedVisibility(visible = isEnsuringSession) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
androidx.compose.material3.Text(
text = stringResource(Res.string.establishing_session),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}

View File

@@ -37,6 +37,8 @@ internal fun handleNodeAction(
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum)
is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {

View File

@@ -119,7 +119,16 @@ fun NodeDetailList(
}
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
if (!uiState.metricsState.isManaged) {
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
item {
AdministrationSection(
node = node,
metricsState = uiState.metricsState,
onAction = onAction,
onFirmwareSelect = onFirmwareSelect,
sessionStatus = uiState.sessionStatus,
isEnsuringSession = uiState.isEnsuringSession,
)
}
}
}
}

View File

@@ -67,6 +67,7 @@ fun NodeDetailScreen(
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) { viewModel.navigationEvents.collect { onNavigate(it) } }
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,

View File

@@ -20,19 +20,32 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.EnsureSessionResult
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.remote_admin_unreachable
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
@@ -51,6 +64,8 @@ data class NodeDetailUiState(
val availableLogs: Set<LogsType> = emptySet(),
val lastTracerouteTime: Long? = null,
val lastRequestNeighborsTime: Long? = null,
val sessionStatus: SessionStatus = SessionStatus.NoSession,
val isEnsuringSession: Boolean = false,
)
/**
@@ -58,12 +73,16 @@ data class NodeDetailUiState(
*/
@OptIn(ExperimentalCoroutinesApi::class)
@KoinViewModel
@Suppress("LongParameterList")
class NodeDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val serviceRepository: ServiceRepository,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase,
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase,
private val snackbarManager: SnackbarManager,
) : ViewModel() {
private val nodeIdFromRoute: Int? = savedStateHandle.get<Int>("destNum")
@@ -73,12 +92,32 @@ class NodeDetailViewModel(
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
.distinctUntilChanged()
private val isEnsuringSession = MutableStateFlow(false)
private val sessionStatusFlow =
activeNodeId.flatMapLatest { nodeId ->
if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId)
}
/** One-shot navigation events from session-bearing actions (e.g. successful remote-admin opens). */
private val _navigationEvents = Channel<Route>(capacity = Channel.BUFFERED)
val navigationEvents: Flow<Route> = _navigationEvents.receiveAsFlow()
/** Primary UI state stream, combining identity, metrics, and global device metadata. */
val uiState: StateFlow<NodeDetailUiState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
getNodeDetailsUseCase(nodeId)
if (nodeId == null) {
flowOf(NodeDetailUiState())
} else {
combine(getNodeDetailsUseCase(nodeId), sessionStatusFlow, isEnsuringSession) {
base,
sessionStatus,
ensuring,
->
base.copy(sessionStatus = sessionStatus, isEnsuringSession = ensuring)
}
}
}
.stateInWhileSubscribed(initialValue = NodeDetailUiState())
@@ -117,6 +156,37 @@ class NodeDetailViewModel(
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
/**
* Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a
* snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout].
*/
fun openRemoteAdmin(destNum: Int) {
if (isEnsuringSession.value) return
viewModelScope.launch {
isEnsuringSession.value = true
try {
when (ensureRemoteAdminSession(destNum)) {
EnsureSessionResult.AlreadyActive,
EnsureSessionResult.Refreshed,
-> _navigationEvents.trySend(SettingsRoute.Settings(destNum))
EnsureSessionResult.Disconnected ->
snackbarManager.showSnackbar(
UiText.Resource(Res.string.connect_radio_for_remote_admin).resolve(),
)
EnsureSessionResult.Timeout ->
snackbarManager.showSnackbar(UiText.Resource(Res.string.remote_admin_unreachable).resolve())
}
} finally {
isEnsuringSession.value = false
}
}
}
/**
* Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect.
*/
fun refreshMetadata(destNum: Int) = onServiceAction(ServiceAction.GetDeviceMetadata(destNum))
fun setNodeNotes(nodeNum: Int, notes: String) {
nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
}

View File

@@ -29,6 +29,12 @@ sealed interface NodeDetailAction {
data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction
/** Open the remote-administration screen, ensuring a fresh session passkey first. */
data class OpenRemoteAdmin(val nodeNum: Int) : NodeDetailAction
/** Force-refresh device metadata (firmware version, edition, role) for the given node. */
data class RefreshMetadata(val nodeNum: Int) : NodeDetailAction
data object ShareContact : NodeDetailAction
// Opens the compass sheet scoped to a target node and the users preferred units.

View File

@@ -25,12 +25,17 @@ import dev.mokkery.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -48,11 +53,15 @@ class HandleNodeActionTest {
private val nodeRequestActions: NodeRequestActions = mock()
private val serviceRepository: ServiceRepository = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
private val snackbarManager: SnackbarManager = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
}
@AfterTest
@@ -86,5 +95,8 @@ class HandleNodeActionTest {
nodeRequestActions = nodeRequestActions,
serviceRepository = serviceRepository,
getNodeDetailsUseCase = getNodeDetailsUseCase,
ensureRemoteAdminSession = ensureRemoteAdminSession,
observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus,
snackbarManager = snackbarManager,
)
}

View File

@@ -27,12 +27,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.proto.User
@@ -51,12 +56,16 @@ class NodeDetailViewModelTest {
private val nodeRequestActions: NodeRequestActions = mock()
private val serviceRepository: ServiceRepository = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
private val snackbarManager: SnackbarManager = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
viewModel = createViewModel(1234)
}
@@ -67,6 +76,9 @@ class NodeDetailViewModelTest {
nodeRequestActions = nodeRequestActions,
serviceRepository = serviceRepository,
getNodeDetailsUseCase = getNodeDetailsUseCase,
ensureRemoteAdminSession = ensureRemoteAdminSession,
observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus,
snackbarManager = snackbarManager,
)
@AfterTest