Skip to content

Commit 419355c

Browse files
committed
fix(firmware): seed bundled releases against the active device database
The process-wide seedChecked flag assumed one successful seed meant every Room database was populated. But the active database switches per selected device, so the first seed could populate the default DB and skip the device-specific DB entirely. Replace seedChecked with a cached decoded snapshot (bundledSnapshot) that avoids re-reading the asset file, but re-evaluates the apply/skip decision against the current active database on every collection. When the device DB switches and is empty, the bundle re-applies. Also removes temporary diagnostic logging.
1 parent 1828fef commit 419355c

3 files changed

Lines changed: 79 additions & 28 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,19 @@ open class FirmwareReleaseRepositoryImpl(
5454
/** Single-flight guard so concurrent collectors share one network refresh. */
5555
private val refreshMutex = Mutex()
5656

57-
/** Guards [seedChecked] so concurrent collectors decode the bundled snapshot at most once per process. */
57+
/**
58+
* Guards [bundledSnapshot] decode so concurrent collectors decode the bundled JSON at most once per process. The
59+
* apply/skip decision itself is re-evaluated every time against the CURRENT active DB — the active Room database
60+
* switches per selected device, so a one-shot seed gate would miss a freshly activated DB whose `firmware_release`
61+
* rows are empty.
62+
*/
5863
private val seedMutex = Mutex()
5964

60-
@Volatile private var seedChecked = false
65+
/** Decoded bundled snapshot cached for the process lifetime; the asset file never changes between launches. */
66+
@Volatile private var bundledSnapshot: NetworkFirmwareReleases? = null
67+
68+
/** Set when the bundled asset is missing or un-decodable, so we don't retry on every collection. */
69+
@Volatile private var bundleDecodeFailed = false
6170

6271
override val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
6372

@@ -87,34 +96,42 @@ open class FirmwareReleaseRepositoryImpl(
8796
/**
8897
* Applies the bundled snapshot per release type whenever it is newer than what is cached for that type — not just
8998
* when the cache is empty. The bundle is refreshed weekly in CI, so an app update carries fresh data even for users
90-
* whose network path to api.meshtastic.org chronically fails. Checked once per process; a cache that is already
91-
* newer (from a successful network refresh) is never regressed, and a type the bundle doesn't ship is left
92-
* untouched.
99+
* whose network path to api.meshtastic.org chronically fails. The decoded snapshot is cached for the process; the
100+
* apply/skip decision is re-evaluated every call against the CURRENT active DB, because that DB switches per
101+
* selected device and a one-shot seed gate would skip a freshly activated DB whose `firmware_release` rows are
102+
* empty. A cache that is already newer (from a successful network refresh) is never regressed, and a type the
103+
* bundle doesn't ship is left untouched.
93104
*/
94105
private suspend fun ensureSeeded() {
95-
if (seedChecked) return
106+
if (bundleDecodeFailed) return // don't retry the bundled asset on every collection once it has failed
96107
seedMutex.withLock {
97-
if (seedChecked) return
98-
safeCatching {
99-
val bundled = assetReader.decode<NetworkFirmwareReleases>("firmware_releases.json", json)?.releases
100-
if (bundled == null) {
101-
Logger.w { "FirmwareReleaseRepository: no bundled releases available to seed from" }
102-
} else {
103-
val toApply =
104-
listOf(
105-
FirmwareReleaseType.STABLE to bundled.stable,
106-
FirmwareReleaseType.ALPHA to bundled.alpha,
107-
)
108-
.filter { (type, releases) -> isBundleNewerFor(type, releases) }
109-
.toMap()
110-
if (toApply.isNotEmpty()) {
111-
Logger.i { "FirmwareReleaseRepository: applying bundled snapshot for ${toApply.keys}" }
112-
localDataSource.replaceFirmwareReleases(toApply)
108+
// Decode the bundled JSON once per process; the asset never changes between launches.
109+
if (bundledSnapshot == null && !bundleDecodeFailed) {
110+
safeCatching { assetReader.decode<NetworkFirmwareReleases>("firmware_releases.json", json) }
111+
.onSuccess { snapshot -> bundledSnapshot = snapshot }
112+
.onFailure { e ->
113+
Logger.w(e) { "FirmwareReleaseRepository: failed to decode bundled JSON" }
114+
bundleDecodeFailed = true
113115
}
116+
// Decode returning null (asset missing) also stops further retries.
117+
if (bundledSnapshot == null && !bundleDecodeFailed) {
118+
Logger.w { "FirmwareReleaseRepository: no bundled releases available to seed from" }
119+
bundleDecodeFailed = true
114120
}
115121
}
116-
.onSuccess { seedChecked = true }
117-
.onFailure { e -> Logger.w(e) { "FirmwareReleaseRepository: failed to seed cache from bundled JSON" } }
122+
123+
val bundled = bundledSnapshot?.releases ?: return
124+
125+
// Re-evaluate against the current active DB on every call — it may have switched devices since the last
126+
// seed.
127+
val toApply =
128+
listOf(FirmwareReleaseType.STABLE to bundled.stable, FirmwareReleaseType.ALPHA to bundled.alpha)
129+
.filter { (type, releases) -> isBundleNewerFor(type, releases) }
130+
.toMap()
131+
if (toApply.isNotEmpty()) {
132+
Logger.i { "FirmwareReleaseRepository: applying bundled snapshot for ${toApply.keys}" }
133+
localDataSource.replaceFirmwareReleases(toApply)
134+
}
118135
}
119136
}
120137

core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,40 @@ class FirmwareReleaseRepositoryImplTest {
161161
}
162162

163163
@Test
164-
fun failedBundledSeedIsRetriedOnNextCollection() = runBlocking {
164+
fun failedBundledDecodeIsNotRetried() = runBlocking {
165+
// A broken bundled asset is a permanent failure — retrying on every collection just burns I/O.
165166
seed.failuresBeforeSuccess = 1
166167
seed.bundled = Releases(stable = listOf(release("v2.7.15.567b8ea")))
167168
api.response = NetworkFirmwareReleases()
168169

169170
val initialEmissions = repository.stableRelease.toList()
170-
171+
// Subsequent collection must NOT retry the decode (the second call would succeed if it did).
171172
val emissions = repository.stableRelease.toList()
172173

173174
assertEquals(null, initialEmissions.first())
174-
assertEquals("v2.7.15.567b8ea", emissions.first()?.id)
175+
assertEquals(null, emissions.first(), "failed bundled decode is permanent; no retry on subsequent collections")
176+
}
177+
178+
@Test
179+
fun reSeedsWhenActiveDatabaseSwitches() = runBlocking {
180+
// Reproduces the original bug: the active Room DB switches per selected device. A process-wide
181+
// seed gate set during the first collection (against the default DB) skipped seeding the newly
182+
// activated device DB, leaving the firmware picker empty.
183+
seed.bundled = Releases(stable = listOf(release("v2.7.15.567b8ea")))
184+
api.response = NetworkFirmwareReleases()
185+
186+
val firstEmissions = repository.stableRelease.toList()
187+
assertEquals("v2.7.15.567b8ea", firstEmissions.first()?.id, "first collection seeds DB-A")
188+
189+
// The selected device's DB becomes active — it has no firmware_release rows.
190+
dbProvider.switchToNewDatabase()
191+
192+
val secondEmissions = repository.stableRelease.toList()
193+
assertEquals(
194+
"v2.7.15.567b8ea",
195+
secondEmissions.first()?.id,
196+
"DB switch re-evaluates the bundled snapshot against the now-empty active DB and re-seeds it",
197+
)
175198
}
176199

177200
@Test

core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseProvider.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,23 @@ import org.meshtastic.core.database.getInMemoryDatabaseBuilder
2424

2525
/** A real [DatabaseProvider] that uses an in-memory database for testing. */
2626
class FakeDatabaseProvider : DatabaseProvider {
27-
private val db: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
27+
private var db: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
2828
private val _currentDb = MutableStateFlow(db)
2929
override val currentDb: StateFlow<MeshtasticDatabase> = _currentDb
3030

3131
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(db)
3232

33+
/**
34+
* Simulates the active-DB switch that happens when the app selects a different device — the new DB has no rows, so
35+
* any cache that lived only in the previous DB is gone. Closes the prior DB to free its resources.
36+
*/
37+
fun switchToNewDatabase() {
38+
val previous = db
39+
db = getInMemoryDatabaseBuilder().build()
40+
_currentDb.value = db
41+
previous.close()
42+
}
43+
3344
fun close() {
3445
db.close()
3546
}

0 commit comments

Comments
 (0)