diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt index 834cff2c2..a2407008b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt @@ -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 } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt index d4e0cdca2..695400cbc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt @@ -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, private val configFlowManager: Lazy, - 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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index fd72ef9c7..b7e56c440 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -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) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 022f3548d..23d07d222 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -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() } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt new file mode 100644 index 000000000..497796085 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt @@ -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 . + */ +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>(persistentMapOf()) + + private val refreshFlow = MutableSharedFlow(extraBufferCapacity = REFRESH_BUFFER) + override val sessionRefreshFlow: SharedFlow = 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 = 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 + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt index b416bca85..e6c2841f1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt @@ -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(MockMode.autofill) private val configHandler = mock(MockMode.autofill) private val configFlowManager = mock(MockMode.autofill) - private val commandSender = mock(MockMode.autofill) + private val sessionManager = mock(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) } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 07c8914ad..fadd19542 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -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(MockMode.autofill) private val radioConfigRepository = mock(MockMode.autofill) private val commandSender = mock(MockMode.autofill) + private val sessionManager = mock(MockMode.autofill) private val nodeManager = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) @@ -124,6 +126,7 @@ class MeshConnectionManagerImplTest { historyManager, radioConfigRepository, commandSender, + sessionManager, nodeManager, analytics, packetRepository, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt new file mode 100644 index 000000000..3109f82ff --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt @@ -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 . + */ +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(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(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() + } + } +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt new file mode 100644 index 000000000..831f17257 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt @@ -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 . + */ +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>() + + @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 + } +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt new file mode 100644 index 000000000..512f597ed --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt @@ -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 . + */ +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 +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt new file mode 100644 index 000000000..af48d0727 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt @@ -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 . + */ +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 = sessionManager.observeSessionStatus(destNum) +} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt new file mode 100644 index 000000000..4554a08be --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt @@ -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 . + */ +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 = MutableSharedFlow(extraBufferCapacity = 8), + ): SessionManager { + val mgr = mock(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(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(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(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(extraBufferCapacity = 8) + val sessionManager = stubSessionManager(refreshFlow = refresh) + val handler = mock(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(extraBufferCapacity = 8) + val sessionManager = stubSessionManager(refreshFlow = refresh) + val handler = mock(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) + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt new file mode 100644 index 000000000..90a66c0ee --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt @@ -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 . + */ +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 +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index f8edeaa73..0443bb0d4 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -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, diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index b99a002de..c5c5bde07 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -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) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt new file mode 100644 index 000000000..253a833b8 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt @@ -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 . + */ +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 + + /** + * 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 +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 505d80821..2ef01ef24 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -816,6 +816,14 @@ Host Metrics Pax Metrics Metadata + Refresh metadata + Connect & administer + Establishing remote session… + Session active + Refresh required + Connect to a radio to administer remote nodes. + Could not reach node — try again or move closer. + Retry Actions Firmware Use 12h clock format diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 23ef010e8..235118876 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -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), + ) + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 559582417..9efc50d5b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -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 -> { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index 03367debf..f19ebc37d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -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, + ) + } } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 951648e29..1d2d3022b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -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, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index e891d8ae0..e2d552c9b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -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 = 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("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(capacity = Channel.BUFFERED) + val navigationEvents: Flow = _navigationEvents.receiveAsFlow() + /** Primary UI state stream, combining identity, metrics, and global device metadata. */ val uiState: StateFlow = 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) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index 1f93a15ba..ee4116fc8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -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 user’s preferred units. diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt index 6bca8822b..c7504dfe4 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt @@ -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, ) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt index c3ed67b5b..fc9ed6b2a 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt @@ -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 diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index f30a12d52..53e2b7323 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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) }, ) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index 773664c1f..295dcb8c5 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -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, ) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 1ee791620..3eba4663b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -100,6 +100,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { 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. */ diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 75f37c06e..37601415e 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -27,6 +27,7 @@ actual fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)?, ) { // TODO: Implement iOS settings main screen } diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index cd7095eae..617cd25b7 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -28,6 +28,7 @@ actual fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)?, ) { DesktopSettingsScreen( settingsViewModel = settingsViewModel,