From 9d3e3a1dfdd24ef5cc107542397aa1e3060c84d6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:02:36 -0500 Subject: [PATCH] fix(firmware): never offer an alpha release older than current stable (#6198) Co-authored-by: Claude Fable 5 --- .../repository/FirmwareReleaseRepositoryImpl.kt | 14 +++++++++++++- .../FirmwareReleaseRepositoryImplTest.kt | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index da3b53e35..287f2fc2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -75,7 +75,8 @@ open class FirmwareReleaseRepositoryImpl( private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow = staleWhileRevalidateFlow( loadFromCache = { ensureSeeded() - localDataSource.getLatestRelease(releaseType)?.asExternalModel() + val latest = localDataSource.getLatestRelease(releaseType)?.asExternalModel() + if (releaseType == FirmwareReleaseType.ALPHA) latest.notBelowStable() else latest }, shouldFetch = { cached -> cached == null || localDataSource.getLatestRelease(releaseType)?.isStale() != false @@ -93,6 +94,17 @@ open class FirmwareReleaseRepositoryImpl( localDataSource.deleteAllFirmwareReleases() } + /** + * After a stable promotion the alpha channel lags behind stable — never offer a downgrade. Reads the cached stable + * row (every refresh writes both types together, so it is as fresh as the alpha row) rather than combining with + * [stableRelease], which would run a second revalidate pipeline — and potentially a duplicate network refresh — for + * every alpha collector. + */ + private suspend fun FirmwareRelease?.notBelowStable(): FirmwareRelease? { + val stable = localDataSource.getLatestRelease(FirmwareReleaseType.STABLE)?.asExternalModel() + return if (this != null && stable != null && asDeviceVersion() < stable.asDeviceVersion()) stable else this + } + /** * Applies the bundled snapshot per release type whenever it is newer than what is cached for that type — not just * when the cache is empty. The bundle is refreshed weekly in CI, so an app update carries fresh data even for users diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt index 393755b85..5b9cc0b54 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt @@ -228,6 +228,18 @@ class FirmwareReleaseRepositoryImplTest { ) } + @Test + fun alphaChannelNeverOffersVersionBelowStable() = runBlocking { + // After a promotion the newest alpha (2.7.25) is older than stable (2.7.26) — offer stable instead. + dao.insert(FirmwareReleaseEntity(id = "v2.7.26.54e0d8d", releaseType = FirmwareReleaseType.STABLE)) + dao.insert(FirmwareReleaseEntity(id = "v2.7.25.104df5f", releaseType = FirmwareReleaseType.ALPHA)) + assertEquals("v2.7.26.54e0d8d", repository.alphaRelease.toList().last()?.id) + + // A genuinely newer alpha is still offered. + dao.insert(FirmwareReleaseEntity(id = "v2.7.27.abc1234", releaseType = FirmwareReleaseType.ALPHA)) + assertEquals("v2.7.27.abc1234", repository.alphaRelease.toList().last()?.id) + } + @Test fun olderBundledSnapshotNeverRegressesCache() = runBlocking { // A successful network refresh left the cache newer than the (weekly) bundle.