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

@@ -21,9 +21,12 @@ import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.NodeIdLookup
import kotlin.time.Clock
@Module
@ComponentScan("org.meshtastic.core.data")
class CoreDataModule {
@Single fun provideMeshDataMapper(nodeIdLookup: NodeIdLookup): MeshDataMapper = MeshDataMapper(nodeIdLookup)
@Single fun provideClock(): Clock = Clock.System
}

View File

@@ -19,10 +19,10 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
@@ -35,19 +35,18 @@ class AdminPacketHandlerImpl(
private val nodeManager: NodeManager,
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
private val sessionManager: SessionManager,
) : AdminPacketHandler {
override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" }
// Guard against clearing a valid passkey: firmware always embeds the key in every
// admin response, but a missing (default-empty) field must not reset the stored value.
// Firmware embeds the session_passkey in every admin response. A missing (default-empty)
// field must not reset stored state, so only record refreshes when bytes arrived.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) {
Logger.d { "Session passkey updated (${incomingPasskey.size} bytes)" }
commandSender.setSessionPasskey(incomingPasskey)
sessionManager.recordSession(packet.from, incomingPasskey)
}
val fromNum = packet.from

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.AirQualityMetrics
@@ -60,7 +61,7 @@ import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList")
@Single
class CommandSenderImpl(
private val packetHandler: PacketHandler,
@@ -68,10 +69,10 @@ class CommandSenderImpl(
private val radioConfigRepository: RadioConfigRepository,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val sessionManager: SessionManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : CommandSender {
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
@@ -93,10 +94,6 @@ class CommandSenderImpl(
return ((next % numPacketIds) + 1L).toInt()
}
override fun setSessionPasskey(key: ByteString) {
sessionPasskey.value = key
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
/**
@@ -174,7 +171,7 @@ class CommandSenderImpl(
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler.sendToRadio(packet)
@@ -186,7 +183,7 @@ class CommandSenderImpl(
wantResponse: Boolean,
initFn: () -> AdminMessage,
): Boolean {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
return packetHandler.sendToRadioAndAwait(packet)

View File

@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
@@ -55,6 +54,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
@@ -80,6 +80,7 @@ class MeshConnectionManagerImpl(
private val historyManager: HistoryManager,
private val radioConfigRepository: RadioConfigRepository,
private val commandSender: CommandSender,
private val sessionManager: SessionManager,
private val nodeManager: NodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
@@ -237,7 +238,7 @@ class MeshConnectionManagerImpl(
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect.
sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect.
locationManager.stop()
mqttManager.stop()
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import okio.ByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.repository.SessionManager
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* In-memory implementation of [SessionManager] backed by an atomicfu-protected [PersistentMap].
*
* Per-node state replaces the single global passkey atomic that previously lived in `CommandSenderImpl`. Without this,
* bouncing remote-admin between two nodes within the firmware's 300 s TTL silently invalidated the first node's session
* because its passkey was overwritten by the second node's response.
*
* Threshold rationale (see `firmware/src/modules/AdminModule.cpp:1460-1481`):
* - Firmware TTL = 300 s, with passkey rotation at the 150 s halfway mark on the next response sent.
* - We treat 240 s as the "active enough to navigate without refreshing" boundary to leave headroom for in-flight
* packets, mesh latency, and clock skew. A user navigated into the remote-admin screen at 299 s would otherwise
* immediately time out on the next request.
*/
@Single
class SessionManagerImpl(private val clock: Clock) : SessionManager {
private val entries = atomic<PersistentMap<Int, SessionEntry>>(persistentMapOf())
private val refreshFlow = MutableSharedFlow<Int>(extraBufferCapacity = REFRESH_BUFFER)
override val sessionRefreshFlow: SharedFlow<Int> = refreshFlow.asSharedFlow()
override fun recordSession(srcNodeNum: Int, passkey: ByteString) {
if (passkey.size == 0) return
val now = clock.now()
entries.update { it.put(srcNodeNum, SessionEntry(passkey, now)) }
Logger.d { "Recorded session refresh from $srcNodeNum (${passkey.size} bytes)" }
refreshFlow.tryEmit(srcNodeNum)
}
override fun getPasskey(destNum: Int): ByteString = entries.value[destNum]?.passkey ?: ByteString.EMPTY
override fun clearAll() {
if (entries.value.isNotEmpty()) {
Logger.d { "Clearing ${entries.value.size} session entries" }
}
entries.value = persistentMapOf()
}
override fun observeSessionStatus(destNum: Int): Flow<SessionStatus> = merge(
flowOf(Unit),
refreshFlow.filter { it == destNum }.map {},
flow {
while (true) {
delay(RECHECK_INTERVAL)
emit(Unit)
}
},
)
.map { computeStatus(destNum) }
.distinctUntilChanged()
private fun computeStatus(destNum: Int): SessionStatus {
val entry = entries.value[destNum] ?: return SessionStatus.NoSession
val age = clock.now() - entry.refreshedAt
return if (age < ACTIVE_THRESHOLD) {
SessionStatus.Active(entry.refreshedAt)
} else {
SessionStatus.Stale(entry.refreshedAt)
}
}
private data class SessionEntry(val passkey: ByteString, val refreshedAt: Instant)
companion object {
/**
* "Active enough to navigate" window. Set below the firmware TTL (300 s) to leave room for packet flight time
* and clock skew so users don't get sent into a screen that immediately times out.
*/
val ACTIVE_THRESHOLD = 240.seconds
/** Re-emit interval for [observeSessionStatus] so the UI transitions Active → Stale without user input. */
val RECHECK_INTERVAL = 60.seconds
private const val REFRESH_BUFFER = 8
}
}

View File

@@ -21,10 +21,10 @@ import dev.mokkery.mock
import dev.mokkery.verify
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -42,7 +42,7 @@ class AdminPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val configHandler = mock<MeshConfigHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val sessionManager = mock<SessionManager>(MockMode.autofill)
private lateinit var handler: AdminPacketHandlerImpl
@@ -55,7 +55,7 @@ class AdminPacketHandlerImplTest {
nodeManager = nodeManager,
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
commandSender = commandSender,
sessionManager = sessionManager,
)
}
@@ -74,16 +74,16 @@ class AdminPacketHandlerImplTest {
handler.handleAdminMessage(packet, myNodeNum)
verify { commandSender.setSessionPasskey(passkey) }
verify { sessionManager.recordSession(myNodeNum, passkey) }
}
@Test
fun `empty session passkey does not clear existing passkey`() {
fun `empty session passkey does not record refresh`() {
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// setSessionPasskey should NOT be called for empty passkey
// recordSession should NOT be called for empty passkey
}
// ---------- get_config_response ----------
@@ -218,7 +218,7 @@ class AdminPacketHandlerImplTest {
handler.handleAdminMessage(packet, myNodeNum)
verify { commandSender.setSessionPasskey(passkey) }
verify { sessionManager.recordSession(myNodeNum, passkey) }
verify { configHandler.handleDeviceConfig(config) }
}
}

View File

@@ -50,6 +50,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.Config
@@ -75,6 +76,7 @@ class MeshConnectionManagerImplTest {
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val sessionManager = mock<SessionManager>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
@@ -124,6 +126,7 @@ class MeshConnectionManagerImplTest {
historyManager,
radioConfigRepository,
commandSender,
sessionManager,
nodeManager,
analytics,
packetRepository,

View File

@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import app.cash.turbine.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okio.ByteString
import org.meshtastic.core.model.SessionStatus
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertSame
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class SessionManagerImplTest {
private class MutableClock(var now: Instant = Instant.fromEpochSeconds(1_700_000_000)) : Clock {
override fun now(): Instant = now
}
private val nodeA = 0xAAAA
private val nodeB = 0xBBBB
private val keyA = ByteString.of(1, 2, 3, 4, 5, 6, 7, 8)
private val keyB = ByteString.of(9, 8, 7, 6, 5, 4, 3, 2)
@Test
fun `recordSession stores per-node passkeys without overwriting siblings`() {
val mgr = SessionManagerImpl(MutableClock())
mgr.recordSession(nodeA, keyA)
mgr.recordSession(nodeB, keyB)
assertEquals(keyA, mgr.getPasskey(nodeA))
assertEquals(keyB, mgr.getPasskey(nodeB))
}
@Test
fun `recordSession with empty passkey is a no-op`() {
val mgr = SessionManagerImpl(MutableClock())
mgr.recordSession(nodeA, ByteString.EMPTY)
assertSame(ByteString.EMPTY, mgr.getPasskey(nodeA))
}
@Test
fun `clearAll wipes per-node entries`() {
val mgr = SessionManagerImpl(MutableClock())
mgr.recordSession(nodeA, keyA)
mgr.recordSession(nodeB, keyB)
mgr.clearAll()
assertSame(ByteString.EMPTY, mgr.getPasskey(nodeA))
assertSame(ByteString.EMPTY, mgr.getPasskey(nodeB))
}
@Test
fun `observeSessionStatus emits NoSession initially when no key recorded`() = runTest {
val mgr = SessionManagerImpl(MutableClock())
assertEquals(SessionStatus.NoSession, mgr.observeSessionStatus(nodeA).first())
}
@Test
fun `observeSessionStatus reports Active for a fresh recording`() = runTest {
val clock = MutableClock()
val mgr = SessionManagerImpl(clock)
mgr.recordSession(nodeA, keyA)
val status = mgr.observeSessionStatus(nodeA).first()
assertIs<SessionStatus.Active>(status)
assertEquals(clock.now, status.refreshedAt)
}
@Test
fun `observeSessionStatus reports Stale once age exceeds threshold`() = runTest {
val clock = MutableClock()
val mgr = SessionManagerImpl(clock)
mgr.recordSession(nodeA, keyA)
// Age past the 240s active threshold; still under firmware TTL of 300s.
clock.now = clock.now.plus(250.seconds)
val status = mgr.observeSessionStatus(nodeA).first()
assertIs<SessionStatus.Stale>(status)
}
@Test
fun `sessionRefreshFlow emits srcNodeNum on each non-empty recording`() = runTest {
val mgr = SessionManagerImpl(MutableClock())
mgr.sessionRefreshFlow.test {
mgr.recordSession(nodeA, keyA)
assertEquals(nodeA, awaitItem())
mgr.recordSession(nodeB, keyB)
assertEquals(nodeB, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.session
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import kotlin.time.Duration.Companion.seconds
/**
* Ensures a remote-admin session exists for the target node, dispatching a metadata request and awaiting a refreshed
* passkey if necessary.
*
* Why this exists: the firmware embeds an 8-byte rotating passkey in every admin response and rejects admin traffic
* lacking a fresh key (`firmware/src/modules/AdminModule.cpp:1460-1481`). Before this use case the UI silently tunneled
* the user into a remote-admin screen that immediately failed if no metadata had been requested first.
*
* Concurrency model:
* - One in-flight ensure per `destNum`. Concurrent callers dedupe onto the same `Deferred` so a double-tap doesn't
* blast two metadata requests at the radio.
* - The refresh-flow subscription is established **before** the metadata request is dispatched to avoid losing the
* response on the inherently raceful `MutableSharedFlow`.
* - The `withTimeoutOrNull` is a UX deadline only — late responses still update the durable `SessionStatus` flow that
* the UI observes, so a "Timeout" outcome here can self-heal in the chip without re-tapping.
*/
@Single
open class EnsureRemoteAdminSessionUseCase(
private val sessionManager: SessionManager,
private val meshActionHandler: MeshActionHandler,
private val serviceRepository: ServiceRepository,
@Named("ServiceScope") private val serviceScope: CoroutineScope,
) {
private val mutex = Mutex()
private val inFlight = mutableMapOf<Int, Deferred<EnsureSessionResult>>()
@Suppress("ReturnCount")
open suspend operator fun invoke(destNum: Int): EnsureSessionResult {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
return EnsureSessionResult.Disconnected
}
if (sessionManager.observeSessionStatus(destNum).first() is SessionStatus.Active) {
return EnsureSessionResult.AlreadyActive
}
val deferred =
mutex.withLock {
inFlight[destNum]
?: serviceScope
.async(start = CoroutineStart.LAZY) { runEnsure(destNum) }
.also { inFlight[destNum] = it }
}
return try {
deferred.await()
} finally {
mutex.withLock { if (inFlight[destNum] === deferred) inFlight.remove(destNum) }
}
}
private suspend fun runEnsure(destNum: Int): EnsureSessionResult {
Logger.d { "EnsureRemoteAdminSession dispatching metadata request to $destNum" }
return withTimeoutOrNull(UX_TIMEOUT) {
// Subscribe BEFORE dispatching so we don't miss the refresh emission.
val refreshed =
serviceScope.async(start = CoroutineStart.UNDISPATCHED) {
sessionManager.sessionRefreshFlow.filter { it == destNum }.first()
}
try {
meshActionHandler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum))
refreshed.await()
EnsureSessionResult.Refreshed
} finally {
refreshed.cancel()
}
} ?: EnsureSessionResult.Timeout
}
companion object {
/**
* UX deadline for surfacing a result to the user. The metadata request keeps flying after this — late responses
* still update the durable `SessionStatus` flow.
*/
val UX_TIMEOUT = 10.seconds
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.session
/**
* Transient outcome of a single call to [EnsureRemoteAdminSessionUseCase]. This is the *event* the UI reacts to
* (snackbar / navigate / disable button) — distinct from the durable `SessionStatus` flow used by chips and gates.
*/
sealed interface EnsureSessionResult {
/** A fresh session was already on file; no admin packet was sent. */
data object AlreadyActive : EnsureSessionResult
/** A metadata request was dispatched and a passkey-bearing response was observed within the UX deadline. */
data object Refreshed : EnsureSessionResult
/**
* The metadata request was dispatched but no response arrived within the UX deadline. The request is still in
* flight and a late response will still update the durable `SessionStatus` flow.
*/
data object Timeout : EnsureSessionResult
/** The radio is not in [org.meshtastic.core.model.ConnectionState.Connected]; no packet was sent. */
data object Disconnected : EnsureSessionResult
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.session
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.repository.SessionManager
/**
* Thin wrapper that exposes the durable per-node [SessionStatus] flow to UI consumers without leaking the
* [SessionManager] into ViewModels.
*/
@Single
open class ObserveRemoteAdminSessionStatusUseCase(private val sessionManager: SessionManager) {
open operator fun invoke(destNum: Int): Flow<SessionStatus> = sessionManager.observeSessionStatus(destNum)
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.session
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Clock
@OptIn(ExperimentalCoroutinesApi::class)
class EnsureRemoteAdminSessionUseCaseTest {
private val destNum = 0xCAFE
private fun stubSessionManager(
initialStatus: SessionStatus = SessionStatus.NoSession,
refreshFlow: MutableSharedFlow<Int> = MutableSharedFlow(extraBufferCapacity = 8),
): SessionManager {
val mgr = mock<SessionManager>(MockMode.autofill)
every { mgr.observeSessionStatus(any()) } returns flowOf(initialStatus)
every { mgr.sessionRefreshFlow } returns refreshFlow
every { mgr.getPasskey(any()) } returns ByteString.EMPTY
return mgr
}
private fun connectedRepo(state: ConnectionState = ConnectionState.Connected): ServiceRepository {
val repo = mock<ServiceRepository>(MockMode.autofill)
every { repo.connectionState } returns MutableStateFlow(state)
return repo
}
@Test
fun `returns Disconnected without dispatching when not connected`() = runTest {
val sessionManager = stubSessionManager()
val handler = mock<MeshActionHandler>(MockMode.autofill)
val useCase =
EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(ConnectionState.Disconnected), this)
val result = useCase(destNum)
assertEquals(EnsureSessionResult.Disconnected, result)
}
@Test
fun `returns AlreadyActive without dispatching when status already Active`() = runTest {
val active = SessionStatus.Active(Clock.System.now())
val sessionManager = stubSessionManager(initialStatus = active)
val handler = mock<MeshActionHandler>(MockMode.autofill)
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
val result = useCase(destNum)
assertEquals(EnsureSessionResult.AlreadyActive, result)
}
@Test
fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest {
val refresh = MutableSharedFlow<Int>(extraBufferCapacity = 8)
val sessionManager = stubSessionManager(refreshFlow = refresh)
val handler = mock<MeshActionHandler>(MockMode.autofill)
// Simulate the radio responding by emitting on the refresh flow when the metadata request fires.
everySuspend { handler.onServiceAction(any()) } calls
{
refresh.tryEmit(destNum)
Unit
}
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
val result = useCase(destNum)
assertEquals(EnsureSessionResult.Refreshed, result)
verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) }
}
@Test
fun `returns Timeout when no refresh arrives within deadline`() = runTest {
val refresh = MutableSharedFlow<Int>(extraBufferCapacity = 8)
val sessionManager = stubSessionManager(refreshFlow = refresh)
val handler = mock<MeshActionHandler>(MockMode.autofill)
everySuspend { handler.onServiceAction(any()) } returns Unit
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
var observed: EnsureSessionResult? = null
val job = launch { observed = useCase(destNum) }
advanceTimeBy(EnsureRemoteAdminSessionUseCase.UX_TIMEOUT.inWholeMilliseconds + 100)
advanceUntilIdle()
job.join()
assertEquals(EnsureSessionResult.Timeout, observed)
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import kotlin.time.Instant
/**
* Durable per-node remote-administration session status, derived from the time of the last admin response that carried
* a `session_passkey` from the target node.
*
* The Meshtastic firmware enforces a 300 s session TTL and rotates the passkey at the 150 s mark when sending any admin
* response (see `firmware/src/modules/AdminModule.cpp:1460-1481`). To leave headroom for in-flight packets and clock
* skew, the Android client treats sessions older than 240 s as [Stale] — still potentially usable for a single ping but
* the UI should refresh before navigating the user into a screen that fires more admin requests.
*/
sealed interface SessionStatus {
/** No admin response with a session passkey has ever been observed for this node since connect. */
data object NoSession : SessionStatus
/** A fresh session passkey is on file and is well within the firmware TTL. */
data class Active(val refreshedAt: Instant) : SessionStatus
/**
* A session passkey is on file but the firmware may have already rotated it or be about to expire it; refresh
* before sending further admin traffic.
*/
data class Stale(val refreshedAt: Instant) : SessionStatus
}

View File

@@ -66,6 +66,10 @@ class MockRadioTransport(
companion object {
private const val MY_NODE = 0x42424242
@Suppress("MagicNumber")
private val FAKE_SESSION_PASSKEY: okio.ByteString =
okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
}
private var currentPacketId = 50
@@ -297,7 +301,9 @@ class MockRadioTransport(
)
private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) {
val adminMsg = AdminMessage().initFn()
// Embed a deterministic 8-byte fake passkey so SessionManager can record a session refresh — mirrors what real
// firmware always attaches to admin responses (see firmware/src/modules/AdminModule.cpp:1460-1481).
val adminMsg = AdminMessage().initFn().copy(session_passkey = FAKE_SESSION_PASSKEY)
val p =
makeDataPacket(
fromIn,

View File

@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.repository
import okio.ByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.proto.AdminMessage
@@ -38,9 +37,6 @@ interface CommandSender {
/** Generates a new unique packet ID. */
fun generatePacketId(): Int
/** Sets the session passkey for admin messages. */
fun setSessionPasskey(key: ByteString)
/** Sends a data packet to the mesh. */
fun sendData(p: DataPacket)

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import okio.ByteString
import org.meshtastic.core.model.SessionStatus
/**
* Owns per-node remote-administration session state — the session passkey the firmware embeds in every admin response
* and the timestamp it was last refreshed at.
*
* Replaces the single global passkey atomic that previously lived in `CommandSenderImpl`, which silently invalidated
* the session of node A as soon as node B responded with a different key (the multi-remote-admin bug).
*
* Lifecycle:
* - [recordSession] is called by the admin packet handler whenever an inbound admin response carries a non-empty
* `session_passkey`.
* - [getPasskey] is read on the send path to attach the appropriate per-destination key.
* - [clearAll] is called on radio teardown to prevent stale keys from surviving a reconnect.
*/
interface SessionManager {
/** Record an inbound session refresh from [srcNodeNum]. No-op for empty [passkey]. */
fun recordSession(srcNodeNum: Int, passkey: ByteString)
/** Returns the most recently observed passkey for [destNum], or [ByteString.EMPTY] if none. */
fun getPasskey(destNum: Int): ByteString
/** Clears all per-node session state. Call on radio disconnect / teardown. */
fun clearAll()
/**
* Hot stream of `srcNodeNum` values, emitted exactly once per call to [recordSession] with a non-empty passkey.
* Used by `EnsureRemoteAdminSessionUseCase` to await a session refresh from a specific node without polling.
*
* Backed by a `MutableSharedFlow` with no replay; subscribers must subscribe **before** dispatching the request
* that triggers the refresh.
*/
val sessionRefreshFlow: SharedFlow<Int>
/**
* Cold per-node [SessionStatus] flow. Emits the current status synchronously on subscription and re-emits whenever
* the underlying state crosses the staleness threshold.
*/
fun observeSessionStatus(destNum: Int): Flow<SessionStatus>
}

View File

@@ -816,6 +816,14 @@
<string name="request_host_metrics">Host Metrics</string>
<string name="request_pax_metrics">Pax Metrics</string>
<string name="request_metadata">Metadata</string>
<string name="refresh_metadata">Refresh metadata</string>
<string name="establish_session">Connect &amp; administer</string>
<string name="establishing_session">Establishing remote session…</string>
<string name="session_active">Session active</string>
<string name="session_refresh_required">Refresh required</string>
<string name="connect_radio_for_remote_admin">Connect to a radio to administer remote nodes.</string>
<string name="remote_admin_unreachable">Could not reach node — try again or move closer.</string>
<string name="retry">Retry</string>
<string name="actions">Actions</string>
<string name="firmware">Firmware</string>
<string name="use_12h_format">Use 12h clock format</string>

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

View File

@@ -80,6 +80,7 @@ fun SettingsScreen(
viewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onBack: (() -> Unit)? = null,
) {
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
@@ -167,6 +168,8 @@ fun SettingsScreen(
Scaffold(
topBar = {
// Show back arrow when remotely administering (caller supplies onBack and we're not on the local node).
val showBack = onBack != null && !state.isLocal
MainAppBar(
title = stringResource(Res.string.bottom_nav_settings),
subtitle =
@@ -178,8 +181,8 @@ fun SettingsScreen(
},
ourNode = ourNode,
showNodeChip = ourNode != null && isConnected && state.isLocal,
canNavigateUp = false,
onNavigateUp = {},
canNavigateUp = showBack,
onNavigateUp = { onBack?.invoke() },
actions = {},
onClickChip = { node -> onClickNodeChip(node.num) },
)

View File

@@ -28,11 +28,13 @@ actual fun SettingsMainScreen(
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
onBack: (() -> Unit)?,
) {
SettingsScreen(
settingsViewModel = settingsViewModel,
viewModel = radioConfigViewModel,
onClickNodeChip = onClickNodeChip,
onNavigate = onNavigate,
onBack = onBack,
)
}

View File

@@ -100,6 +100,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
radioConfigViewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
onNavigate = { backStack.add(it) },
onBack = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
@@ -233,6 +234,7 @@ expect fun SettingsMainScreen(
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
onBack: (() -> Unit)? = null,
)
/** Expect declarations for platform-specific config screens. */

View File

@@ -27,6 +27,7 @@ actual fun SettingsMainScreen(
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
onBack: (() -> Unit)?,
) {
// TODO: Implement iOS settings main screen
}

View File

@@ -28,6 +28,7 @@ actual fun SettingsMainScreen(
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
onBack: (() -> Unit)?,
) {
DesktopSettingsScreen(
settingsViewModel = settingsViewModel,