mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -27,6 +27,7 @@ actual fun SettingsMainScreen(
|
||||
radioConfigViewModel: RadioConfigViewModel,
|
||||
onClickNodeChip: (Int) -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
// TODO: Implement iOS settings main screen
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ actual fun SettingsMainScreen(
|
||||
radioConfigViewModel: RadioConfigViewModel,
|
||||
onClickNodeChip: (Int) -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
DesktopSettingsScreen(
|
||||
settingsViewModel = settingsViewModel,
|
||||
|
||||
Reference in New Issue
Block a user