diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt new file mode 100644 index 000000000..5b3b758bf --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 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.database.dao + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.DiscoveredNodeEntity +import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity +import org.meshtastic.core.database.entity.DiscoverySessionEntity + +/** + * A switch-aware [DiscoveryDao] that resolves the active database on every call instead of pinning the one that was + * current at injection time. This is what Koin hands to `feature:discovery` consumers (ViewModels and the scan engine), + * which hold their DAO for their whole lifetime: + * - Flow methods re-latch via `currentDb.flatMapLatest`, so an open discovery screen follows a device/DB switch instead + * of watching the old database forever. + * - Suspend methods go through [DatabaseProvider.withDb], so writes register with the cross-transport merge drain + * barrier (a mid-scan merge can't snapshot-then-retire the DB underneath an in-flight session write) and every call + * picks up withDb's closed-pool retry — a pinned DAO used to throw unrecoverably once its DB was retired. + * + * `withDb` only returns null when no database is open, which [DatabaseProvider] guarantees can't happen (the default DB + * is the floor), so the non-null coercions below are structural, not behavioral. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions") +class SwitchingDiscoveryDao(private val dbManager: DatabaseProvider) : DiscoveryDao { + + // region Session operations + + override suspend fun insertSession(session: DiscoverySessionEntity): Long = + checkNotNull(dbManager.withDb { it.discoveryDao().insertSession(session) }) + + override suspend fun updateSession(session: DiscoverySessionEntity) { + dbManager.withDb { it.discoveryDao().updateSession(session) } + } + + override fun getAllSessions(): Flow> = + dbManager.currentDb.flatMapLatest { it.discoveryDao().getAllSessions() } + + override suspend fun getAllSessionsSnapshot(): List = + dbManager.withDb { it.discoveryDao().getAllSessionsSnapshot() }.orEmpty() + + override suspend fun getSession(sessionId: Long): DiscoverySessionEntity? = + dbManager.withDb { it.discoveryDao().getSession(sessionId) } + + override fun getSessionFlow(sessionId: Long): Flow = + dbManager.currentDb.flatMapLatest { it.discoveryDao().getSessionFlow(sessionId) } + + override suspend fun deleteSession(sessionId: Long) { + dbManager.withDb { it.discoveryDao().deleteSession(sessionId) } + } + + override suspend fun markInterruptedSessions() { + dbManager.withDb { it.discoveryDao().markInterruptedSessions() } + } + + override suspend fun getInterruptedSession(deviceAddress: String): DiscoverySessionEntity? = + dbManager.withDb { it.discoveryDao().getInterruptedSession(deviceAddress) } + + // endregion + + // region Preset result operations + + override suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long = + checkNotNull(dbManager.withDb { it.discoveryDao().insertPresetResult(result) }) + + override suspend fun updatePresetResult(result: DiscoveryPresetResultEntity) { + dbManager.withDb { it.discoveryDao().updatePresetResult(result) } + } + + override suspend fun getPresetResults(sessionId: Long): List = + dbManager.withDb { it.discoveryDao().getPresetResults(sessionId) }.orEmpty() + + override fun getPresetResultsFlow(sessionId: Long): Flow> = + dbManager.currentDb.flatMapLatest { it.discoveryDao().getPresetResultsFlow(sessionId) } + + // endregion + + // region Discovered node operations + + override suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long = + checkNotNull(dbManager.withDb { it.discoveryDao().insertDiscoveredNode(node) }) + + override suspend fun insertDiscoveredNodes(nodes: List) { + dbManager.withDb { it.discoveryDao().insertDiscoveredNodes(nodes) } + } + + override suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) { + dbManager.withDb { it.discoveryDao().updateDiscoveredNode(node) } + } + + override suspend fun getDiscoveredNodes(presetResultId: Long): List = + dbManager.withDb { it.discoveryDao().getDiscoveredNodes(presetResultId) }.orEmpty() + + override fun getDiscoveredNodesFlow(presetResultId: Long): Flow> = + dbManager.currentDb.flatMapLatest { it.discoveryDao().getDiscoveredNodesFlow(presetResultId) } + + override suspend fun getUniqueNodeNums(sessionId: Long): List = + dbManager.withDb { it.discoveryDao().getUniqueNodeNums(sessionId) }.orEmpty() + + // endregion + + // region Aggregate queries + + override suspend fun getUniqueNodeCount(sessionId: Long): Int = + dbManager.withDb { it.discoveryDao().getUniqueNodeCount(sessionId) } ?: 0 + + override suspend fun getMaxDistance(sessionId: Long): Double? = + dbManager.withDb { it.discoveryDao().getMaxDistance(sessionId) } + + override suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity? = + dbManager.withDb { it.discoveryDao().getSessionWithResults(sessionId) } + + // endregion +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt index 4328cfe6e..8f30bf3be 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt @@ -24,6 +24,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider import org.meshtastic.core.database.createDatabaseDataStore import org.meshtastic.core.database.dao.DiscoveryDao +import org.meshtastic.core.database.dao.SwitchingDiscoveryDao @Module @ComponentScan("org.meshtastic.core.database") @@ -32,7 +33,11 @@ class CoreDatabaseModule { @Named("DatabaseDataStore") fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs") + /** + * Long-lived consumers (discovery ViewModels, the scan engine) hold this DAO across device/DB switches, so hand + * them the switch-aware delegate — never a DAO pinned to the injection-time `currentDb.value`, which would keep + * reading a stale DB and crash once a cross-transport merge retires it. See [SwitchingDiscoveryDao]. + */ @Factory - fun provideDiscoveryDao(databaseProvider: DatabaseProvider): DiscoveryDao = - databaseProvider.currentDb.value.discoveryDao() + fun provideDiscoveryDao(databaseProvider: DatabaseProvider): DiscoveryDao = SwitchingDiscoveryDao(databaseProvider) } diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDaoTest.kt new file mode 100644 index 000000000..e3d4af800 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDaoTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 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.database.dao + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.MeshtasticDatabase +import org.meshtastic.core.database.entity.DiscoverySessionEntity +import org.meshtastic.core.database.getInMemoryDatabaseBuilder +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Regression for #6235: the DAO Koin hands to `feature:discovery` used to be pinned to the injection-time database, so + * a device/DB switch left long-lived consumers reading (and writing!) the old DB. [SwitchingDiscoveryDao] must resolve + * the active database per call/collection instead. + */ +class SwitchingDiscoveryDaoTest { + + private val dbA: MeshtasticDatabase = getInMemoryDatabaseBuilder().build() + private val dbB: MeshtasticDatabase = getInMemoryDatabaseBuilder().build() + private val provider = TestProvider(dbA) + private val dao = SwitchingDiscoveryDao(provider) + + @AfterTest + fun tearDown() { + dbA.close() + dbB.close() + } + + private fun session(timestamp: Long) = + DiscoverySessionEntity(timestamp = timestamp, presetsScanned = "LongFast", homePreset = "LongFast") + + @Test + fun suspendCallsResolveTheCurrentDbPerCall() = runTest { + dao.insertSession(session(timestamp = 1)) + assertEquals(1, dbA.discoveryDao().getAllSessionsSnapshot().size, "first insert lands in the active DB (A)") + + provider.switchTo(dbB) + + dao.insertSession(session(timestamp = 2)) + assertEquals(1, dbA.discoveryDao().getAllSessionsSnapshot().size, "old DB untouched after switch") + assertEquals(1, dbB.discoveryDao().getAllSessionsSnapshot().size, "post-switch insert lands in the new DB (B)") + assertEquals(1, dao.getAllSessionsSnapshot().size, "reads resolve the new DB too") + } + + @Test + fun flowsRelatchOntoTheCurrentDb() = runTest { + dao.insertSession(session(timestamp = 1)) + + // One live collection end-to-end: the first emission (DB A's session) triggers the switch, and the SAME + // collector must then receive DB B's empty state. A delegate that resolved currentDb only at flow creation + // would never emit the empty list, and the test would fail on runTest's timeout. + var sawDbA = false + val relatched = + dao.getAllSessions() + .onEach { sessions -> + if (sessions.isNotEmpty()) { + sawDbA = true + provider.switchTo(dbB) + } + } + .first { it.isEmpty() } + + assertTrue(sawDbA, "collector observed DB A's session before the switch") + assertEquals(0, relatched.size, "the same collector re-latched onto the new (empty) DB") + } + + /** Minimal [DatabaseProvider] whose active DB the test can swap, mirroring a device/DB switch. */ + private class TestProvider(db: MeshtasticDatabase) : DatabaseProvider { + private val _currentDb = MutableStateFlow(db) + override val currentDb: StateFlow = _currentDb + + override suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(_currentDb.value) + + fun switchTo(db: MeshtasticDatabase) { + _currentDb.value = db + } + } +}