From 5b0d046d42317b21f0088abda204e87f15ebcc8f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:41:19 -0500 Subject: [PATCH] feat(firmware): nightly preview channel behind the hidden-features unlock (#6225) Co-authored-by: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 1 + .../core/common/state/HiddenFeaturesUnlock.kt | 37 ++++++++++ .../FirmwareReleaseRepositoryImpl.kt | 18 ++++- .../DeviceLinkRepositoryImplTest.kt | 3 + .../EventFirmwareRepositoryImplTest.kt | 3 + .../FirmwareReleaseRepositoryImplTest.kt | 56 ++++++++++++++- .../database/entity/FirmwareReleaseEntity.kt | 3 + .../core/model/NetworkFirmwareRelease.kt | 30 ++++++++ .../FirmwareReleaseRemoteDataSource.kt | 5 ++ .../core/network/service/ApiService.kt | 32 +++++++++ .../repository/FirmwareReleaseRepository.kt | 6 ++ .../composeResources/values/strings.xml | 1 + .../testing/FakeFirmwareReleaseRepository.kt | 10 ++- .../feature/firmware/FirmwareRetriever.kt | 29 ++++++-- .../feature/firmware/FirmwareUpdateScreen.kt | 42 +++++------ .../firmware/FirmwareUpdateViewModel.kt | 15 +++- .../firmware/CommonFirmwareRetrieverTest.kt | 71 +++++++++++++++++++ .../firmware/FirmwareUpdateIntegrationTest.kt | 2 + .../firmware/FirmwareUpdateViewModelTest.kt | 33 +++++++++ .../FirmwareUpdateViewModelFileTest.kt | 2 + .../feature/settings/SettingsScreen.kt | 6 +- .../settings/component/AppInfoSection.kt | 20 +++--- .../settings/ModuleConfigurationScreen.kt | 6 +- .../feature/settings/SettingsViewModel.kt | 11 +-- .../settings/navigation/SettingsNavigation.kt | 4 +- .../feature/settings/SettingsViewModelTest.kt | 10 +-- .../feature/settings/DesktopSettingsScreen.kt | 24 +++---- 27 files changed, 411 insertions(+), 69 deletions(-) create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/HiddenFeaturesUnlock.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f566b506c8..a49cd61af5 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -641,6 +641,7 @@ firmware_update_method_detail firmware_update_method_usb firmware_update_method_wifi firmware_update_missing_target +firmware_update_nightly firmware_update_no_device firmware_update_node_info_missing firmware_update_not_found_in_release diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/HiddenFeaturesUnlock.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/HiddenFeaturesUnlock.kt new file mode 100644 index 0000000000..5bc99fe6b9 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/HiddenFeaturesUnlock.kt @@ -0,0 +1,37 @@ +/* + * 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.common.state + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.Single + +/** + * Process-scoped easter-egg unlock (tapping the app version row in Settings five times). Deliberately not persisted — + * like the web flasher's konami code, it re-locks on the next launch. Gates the firmware-excluded module configuration + * screens and the nightly firmware preview channel. + */ +@Single +class HiddenFeaturesUnlock { + private val _unlocked = MutableStateFlow(false) + val unlocked: StateFlow = _unlocked.asStateFlow() + + fun unlock() { + _unlocked.value = true + } +} 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 287f2fc2bb..0c43ce8052 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 @@ -37,6 +37,7 @@ import org.meshtastic.core.database.entity.asExternalModel import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.NetworkFirmwareRelease import org.meshtastic.core.model.NetworkFirmwareReleases +import org.meshtastic.core.model.asFirmwareRelease import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource import org.meshtastic.core.repository.FirmwareReleaseRepository @@ -72,16 +73,21 @@ open class FirmwareReleaseRepositoryImpl( override val alphaRelease: Flow = getLatestFirmware(FirmwareReleaseType.ALPHA) + override val nightlyRelease: Flow = getLatestFirmware(FirmwareReleaseType.NIGHTLY) + private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow = staleWhileRevalidateFlow( loadFromCache = { ensureSeeded() val latest = localDataSource.getLatestRelease(releaseType)?.asExternalModel() + // NIGHTLY is exempt from the below-stable guard: it is an explicit opt-in preview channel. if (releaseType == FirmwareReleaseType.ALPHA) latest.notBelowStable() else latest }, shouldFetch = { cached -> cached == null || localDataSource.getLatestRelease(releaseType)?.isStale() != false }, - fetch = { singleFlightRefresh() }, + // Nightly lives on meshtastic.github.io, not in the API's release list, so it refreshes on its own + // path — regular (locked) users never hit the nightly URL because only unlocked UI collects that flow. + fetch = { if (releaseType == FirmwareReleaseType.NIGHTLY) refreshNightly() else singleFlightRefresh() }, context = dispatchers.default, // No collector blocks on the fetch (cache is emitted first), so let the HttpClient's own // timeout/retry policy bound it — api.meshtastic.org routinely takes 20-60s to serve this list, @@ -176,6 +182,16 @@ open class FirmwareReleaseRepositoryImpl( } } + private suspend fun refreshNightly() { + refreshMutex.withLock { + Logger.d { "FirmwareReleaseRepository: fetching nightly index" } + // A 404 (nothing currently published) returns null and clears any stale nightly row; transport and + // server errors throw before the write and leave the cache untouched. + val nightly = remoteDataSource.getNightlyFirmware()?.asFirmwareRelease() + localDataSource.replaceFirmwareReleases(mapOf(FirmwareReleaseType.NIGHTLY to listOfNotNull(nightly))) + } + } + private fun FirmwareReleaseEntity.isStale(): Boolean = (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS companion object { diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt index 2f3bafbf70..04de52a0ac 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLink import org.meshtastic.core.model.NetworkDeviceLinksResponse +import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.DeviceLinksRemoteDataSource import org.meshtastic.core.network.service.ApiService @@ -48,6 +49,8 @@ class DeviceLinkRepositoryImplTest { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") + override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt index e71b614b9a..1f87142446 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.model.EventFirmwareTheme import org.meshtastic.core.model.EventFirmwareThemeColors import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse +import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.EventFirmwareRemoteDataSource import org.meshtastic.core.network.service.ApiService @@ -55,6 +56,8 @@ class EventFirmwareRepositoryImplTest { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") + override suspend fun getEventFirmware(): EventFirmwareResponse { eventFirmwareCalls++ return response 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 5b9cc0b54c..bd71acc5c8 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 @@ -30,6 +30,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse +import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareRelease import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.Releases @@ -44,14 +45,22 @@ import kotlin.test.assertTrue class FirmwareReleaseRepositoryImplTest { - /** Only [getFirmwareReleases] is exercised; the other endpoints are never called by this repository. */ + /** Only the firmware endpoints are exercised; the others are never called by this repository. */ private class FakeApiService(var response: NetworkFirmwareReleases) : ApiService { + var nightly: NetworkFirmwareNightly? = null + var nightlyUnreachable = false + override suspend fun getDeviceHardware(): List = error("unused") override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = response + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { + if (nightlyUnreachable) error("nightly index unreachable") + return nightly + } + override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") } @@ -240,6 +249,51 @@ class FirmwareReleaseRepositoryImplTest { assertEquals("v2.7.27.abc1234", repository.alphaRelease.toList().last()?.id) } + @Test + fun nightlyFlowMapsPublishedIndex() = runBlocking { + api.nightly = NetworkFirmwareNightly(version = "2.8.0.f52e2ea", commit = "f52e2ea8efa096a") + + val emissions = repository.nightlyRelease.toList() + + val nightly = emissions.last() + assertEquals("v2.8.0.f52e2ea", nightly?.id, "id is derived from the version when absent") + assertEquals("Meshtastic Firmware 2.8.0.f52e2ea Nightly", nightly?.title) + assertEquals("", nightly?.zipUrl, "nightly publishes no release zip") + assertEquals(FirmwareReleaseType.NIGHTLY, nightly?.releaseType) + } + + @Test + fun unpublishedNightlyClearsStaleRow() = runBlocking { + // A nightly was cached, then unpublished upstream (index.json now 404s). + dao.insert(staleRow("v2.8.0.f52e2ea", FirmwareReleaseType.NIGHTLY)) + api.nightly = null + + val emissions = repository.nightlyRelease.toList() + + assertEquals("v2.8.0.f52e2ea", emissions.first()?.id, "stale cache is emitted before the refresh") + assertEquals(null, emissions.last(), "404 clears the cached nightly row") + assertTrue(dao.getReleasesByType(FirmwareReleaseType.NIGHTLY).isEmpty()) + } + + @Test + fun unreachableNightlyIndexLeavesCacheUntouched() = runBlocking { + dao.insert(staleRow("v2.8.0.f52e2ea", FirmwareReleaseType.NIGHTLY)) + api.nightlyUnreachable = true + + val emissions = repository.nightlyRelease.toList() + + assertEquals("v2.8.0.f52e2ea", emissions.last()?.id, "transport errors keep the cached nightly") + } + + @Test + fun nightlyIsExemptFromBelowStableGuard() = runBlocking { + // Unlike alpha, an explicitly selected nightly is offered even when stable is ahead of it. + dao.insert(FirmwareReleaseEntity(id = "v2.9.0.abc1234", releaseType = FirmwareReleaseType.STABLE)) + dao.insert(FirmwareReleaseEntity(id = "v2.8.0.f52e2ea", releaseType = FirmwareReleaseType.NIGHTLY)) + + assertEquals("v2.8.0.f52e2ea", repository.nightlyRelease.toList().first()?.id) + } + @Test fun olderBundledSnapshotNeverRegressesCache() = runBlocking { // A successful network refresh left the cache newer than the (weekly) bundle. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt index 14b5c751a6..5df492b6ca 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt @@ -73,5 +73,8 @@ fun FirmwareRelease.asDeviceVersion(): DeviceVersion = DeviceVersion(id.substrin enum class FirmwareReleaseType { STABLE, ALPHA, + + /** Nightly preview from meshtastic.github.io's `firmware-nightly/` folder; gated behind the modules unlock. */ + NIGHTLY, LOCAL, } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt index b9000d1408..40b85228ac 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt @@ -39,3 +39,33 @@ data class NetworkFirmwareReleases( @SerialName("pullRequests") val pullRequests: List = listOf(), @SerialName("releases") val releases: Releases = Releases(), ) + +/** + * The nightly-preview pointer published to `firmware-nightly/index.json` on meshtastic.github.io. Unlike the release + * channels above it is not served by api.meshtastic.org, and only [version] is guaranteed present — [id] and [title] + * are derived when absent, matching the web flasher's parsing. + */ +@Serializable +data class NetworkFirmwareNightly( + @SerialName("version") val version: String = "", + @SerialName("id") val id: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("commit") val commit: String? = null, +) + +/** + * Normalizes the nightly pointer into the common release shape, or null when the pointer carries no usable version. + * Nightly artifacts are served per-file from the fixed `firmware-nightly/` folder, so there is no release zip. + */ +fun NetworkFirmwareNightly.asFirmwareRelease(): NetworkFirmwareRelease? { + val resolvedId = id?.takeIf { it.isNotBlank() } ?: version.takeIf { it.isNotBlank() }?.let { "v$it" } + resolvedId ?: return null + val resolvedVersion = version.ifBlank { resolvedId.removePrefix("v") } + return NetworkFirmwareRelease( + id = resolvedId, + pageUrl = commit?.takeIf { it.isNotBlank() }?.let { "https://github.com/meshtastic/firmware/commit/$it" } ?: "", + releaseNotes = "", + title = title?.takeIf { it.isNotBlank() } ?: "Meshtastic Firmware $resolvedVersion Nightly", + zipUrl = "", + ) +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt index eccb1dc33e..b0254e2869 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.network import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.service.ApiService @@ -29,4 +30,8 @@ class FirmwareReleaseRemoteDataSource( ) { suspend fun getFirmwareReleases(): NetworkFirmwareReleases = withContext(dispatchers.io) { apiService.getFirmwareReleases() } + + /** The nightly preview pointer from meshtastic.github.io, or null when no nightly is published. */ + suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = + withContext(dispatchers.io) { apiService.getNightlyFirmware() } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt index b0fd88d3f0..8662906b79 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -19,12 +19,26 @@ package org.meshtastic.core.network.service import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse +import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases +/** + * Pointer to the nightly preview build published by CI to meshtastic.github.io. Served from GitHub Pages raw content + * (not api.meshtastic.org) as `text/plain`, so it is parsed manually rather than via content negotiation. + */ +private const val NIGHTLY_INDEX_URL = + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/index.json" + +private val nightlyIndexJson = Json { ignoreUnknownKeys = true } + /** Client for the Meshtastic public API (device hardware catalog and firmware releases). */ interface ApiService { /** Fetches the device hardware catalog from the Meshtastic API. */ @@ -36,6 +50,12 @@ interface ApiService { /** Fetches the list of available firmware releases from the Meshtastic API. */ suspend fun getFirmwareReleases(): NetworkFirmwareReleases + /** + * Fetches the nightly preview build pointer from meshtastic.github.io. Returns null when no nightly is currently + * published (HTTP 404); throws on transport or server errors so callers can distinguish "gone" from "unreachable". + */ + suspend fun getNightlyFirmware(): NetworkFirmwareNightly? + /** Fetches event-firmware display metadata (editions, welcome messages, links) from the Meshtastic API. */ suspend fun getEventFirmware(): EventFirmwareResponse } @@ -56,5 +76,17 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body() + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { + val response = client.get(NIGHTLY_INDEX_URL) + return when { + response.status == HttpStatusCode.NotFound -> null + + response.status.isSuccess() -> + nightlyIndexJson.decodeFromString(response.bodyAsText()) + + else -> error("Unexpected HTTP ${response.status} fetching nightly firmware index") + } + } + override suspend fun getEventFirmware(): EventFirmwareResponse = client.get("resource/eventFirmware").body() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt index 3c97f7753f..93baf47ea4 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt @@ -26,6 +26,12 @@ interface FirmwareReleaseRepository { /** A flow that provides the latest ALPHA firmware release. */ val alphaRelease: Flow + /** + * A flow that provides the current NIGHTLY preview build, or null when none is published. Sourced from + * meshtastic.github.io rather than the API server, and surfaced only behind the hidden-features unlock. + */ + val nightlyRelease: Flow + /** Invalidates the local cache of firmware releases. */ suspend fun invalidateCache() } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index fe50072588..6e558b667e 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -665,6 +665,7 @@ USB File Transfer WiFi OTA Firmware target information is unavailable for this device. + Nightly No device connected Node user information is missing. Could not find firmware for %1$s in release. diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt index 1662567649..534f6fd00a 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -21,10 +21,10 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.repository.FirmwareReleaseRepository /** - * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as + * A test double for [FirmwareReleaseRepository] that exposes stable, alpha, and nightly releases as * [kotlinx.coroutines.flow.MutableStateFlow]s. * - * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. + * Use [setStableRelease], [setAlphaRelease], and [setNightlyRelease] to drive the emitted values. */ class FakeFirmwareReleaseRepository : BaseFake(), @@ -32,9 +32,11 @@ class FakeFirmwareReleaseRepository : private val _stableRelease = mutableStateFlow(null) private val _alphaRelease = mutableStateFlow(null) + private val _nightlyRelease = mutableStateFlow(null) override val stableRelease: Flow = _stableRelease override val alphaRelease: Flow = _alphaRelease + override val nightlyRelease: Flow = _nightlyRelease var invalidateCacheCalls: Int = 0 private set @@ -54,4 +56,8 @@ class FakeFirmwareReleaseRepository : fun setAlphaRelease(release: FirmwareRelease?) { _alphaRelease.value = release } + + fun setNightlyRelease(release: FirmwareRelease?) { + _nightlyRelease.value = release + } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index b70e23d7cb..6a3ae77714 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.model.DeviceHardware import org.meshtastic.feature.firmware.ota.FirmwareHashUtil @@ -150,7 +151,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { hardware: DeviceHardware, onProgress: (Float) -> Unit, ): FirmwareArtifact? { - val manifestUrl = "$FIRMWARE_BASE_URL/firmware-$version/firmware-$target-$version.mt.json" + val manifestUrl = "$FIRMWARE_BASE_URL/${release.artifactFolder}/firmware-$target-$version.mt.json" val text = fileHandler.fetchText(manifestUrl) if (text == null) { @@ -224,7 +225,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { val version = release.id.removePrefix("v") val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } val filename = preferredFilename ?: "firmware-$target-$version$fileSuffix" - val directUrl = "$FIRMWARE_BASE_URL/firmware-$version/$filename" + val directUrl = "$FIRMWARE_BASE_URL/${release.artifactFolder}/$filename" if (fileHandler.checkUrlExists(directUrl)) { try { @@ -236,13 +237,33 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { } } - val zipUrl = resolveZipUrl(release.zipUrl, hardware.architecture) - val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress) + // Nightly builds publish no release zip, so a failed direct download is terminal for them. + val downloadedZip = + if (release.zipUrl.isBlank()) { + Logger.w { "No release zip for ${release.id}; direct download of $filename was the only source" } + null + } else { + val zipUrl = resolveZipUrl(release.zipUrl, hardware.architecture) + try { + fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Release zip download failed for ${release.id}" } + null + } + } return downloadedZip?.let { fileHandler.extractFirmwareFromZip(it, hardware, internalFileExtension, preferredFilename) } } + /** + * The meshtastic.github.io folder holding this release's artifacts. Nightly builds live in the fixed + * `firmware-nightly/` folder (mirroring the web flasher); everything else uses the versioned folder. + */ + private val FirmwareRelease.artifactFolder: String + get() = + if (releaseType == FirmwareReleaseType.NIGHTLY) "firmware-nightly" else "firmware-${id.removePrefix("v")}" + private fun resolveZipUrl(url: String, targetArch: String): String { for (arch in KNOWN_ARCHS) { if (url.contains(arch, ignoreCase = true)) { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 64f963cdb2..baae7aaf6d 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -116,6 +116,7 @@ import org.meshtastic.core.resources.firmware_update_keep_device_close import org.meshtastic.core.resources.firmware_update_latest import org.meshtastic.core.resources.firmware_update_local_file import org.meshtastic.core.resources.firmware_update_method_detail +import org.meshtastic.core.resources.firmware_update_nightly import org.meshtastic.core.resources.firmware_update_rak4631_bootloader_hint import org.meshtastic.core.resources.firmware_update_release_notes import org.meshtastic.core.resources.firmware_update_retry @@ -177,6 +178,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView val currentVersion by viewModel.currentFirmwareVersion.collectAsStateWithLifecycle() val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle() val pendingLocalFirmwareFile by viewModel.pendingLocalFirmwareFile.collectAsStateWithLifecycle() + val nightlyUnlocked by viewModel.nightlyUnlocked.collectAsStateWithLifecycle() var showExitConfirmation by remember { mutableStateOf(false) } @@ -243,6 +245,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView onNavigateUp = onNavigateUp, state = state, selectedReleaseType = selectedReleaseType, + showNightly = nightlyUnlocked, actions = actions, deviceHardware = deviceHardware, currentVersion = currentVersion, @@ -299,6 +302,7 @@ private fun FirmwareUpdateScaffold( onNavigateUp: () -> Unit, state: FirmwareUpdateState, selectedReleaseType: FirmwareReleaseType, + showNightly: Boolean, actions: FirmwareUpdateActions, deviceHardware: DeviceHardware?, currentVersion: String?, @@ -339,7 +343,7 @@ private fun FirmwareUpdateScaffold( (state as? FirmwareUpdateState.Ready)?.isRecovery != true AnimatedVisibility(visible = showReleaseSelector) { Column { - ReleaseTypeSelector(selectedReleaseType, actions.onReleaseTypeSelect) + ReleaseTypeSelector(selectedReleaseType, showNightly, actions.onReleaseTypeSelect) Spacer(Modifier.height(16.dp)) } } @@ -767,29 +771,25 @@ private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDe @Composable private fun ReleaseTypeSelector( selectedReleaseType: FirmwareReleaseType, + showNightly: Boolean, onReleaseTypeSelect: (FirmwareReleaseType) -> Unit, ) { + val types = buildList { + add(FirmwareReleaseType.STABLE to Res.string.firmware_update_stable) + add(FirmwareReleaseType.ALPHA to Res.string.firmware_update_alpha) + // Hidden behind the hidden-features unlock, mirroring the web flasher's konami-gated nightly. + if (showNightly) add(FirmwareReleaseType.NIGHTLY to Res.string.firmware_update_nightly) + add(FirmwareReleaseType.LOCAL to Res.string.firmware_update_local_file) + } SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( - selected = selectedReleaseType == FirmwareReleaseType.STABLE, - onClick = { onReleaseTypeSelect(FirmwareReleaseType.STABLE) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3), - ) { - Text(stringResource(Res.string.firmware_update_stable)) - } - SegmentedButton( - selected = selectedReleaseType == FirmwareReleaseType.ALPHA, - onClick = { onReleaseTypeSelect(FirmwareReleaseType.ALPHA) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3), - ) { - Text(stringResource(Res.string.firmware_update_alpha)) - } - SegmentedButton( - selected = selectedReleaseType == FirmwareReleaseType.LOCAL, - onClick = { onReleaseTypeSelect(FirmwareReleaseType.LOCAL) }, - shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3), - ) { - Text(stringResource(Res.string.firmware_update_local_file)) + types.forEachIndexed { index, (type, label) -> + SegmentedButton( + selected = selectedReleaseType == type, + onClick = { onReleaseTypeSelect(type) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = types.size), + ) { + Text(stringResource(label)) + } } } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 0c60d800a7..bde1d4dcf0 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.di.ApplicationCoroutineScope +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.database.entity.FirmwareRelease @@ -111,6 +112,7 @@ class FirmwareUpdateViewModel( private val usbManager: FirmwareUsbManager, private val fileHandler: FirmwareFileHandler, private val applicationScope: ApplicationCoroutineScope, + private val hiddenFeaturesUnlock: HiddenFeaturesUnlock, ) : ViewModel() { private val _state = MutableStateFlow(FirmwareUpdateState.Idle) @@ -118,6 +120,9 @@ class FirmwareUpdateViewModel( val connectionState = radioController.connectionState + /** The version-row easter egg also unlocks the nightly preview channel, like the web flasher's konami code. */ + val nightlyUnlocked: StateFlow = hiddenFeaturesUnlock.unlocked + private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE) val selectedReleaseType: StateFlow = _selectedReleaseType.asStateFlow() @@ -290,6 +295,12 @@ class FirmwareUpdateViewModel( _currentFirmwareVersion.value = null val type = runCatching { FirmwareReleaseType.valueOf(recovery.releaseType) }.getOrDefault(FirmwareReleaseType.STABLE) + // A nightly recovery record can only exist if the user had unlocked the hidden channel and deliberately + // flashed nightly before the interruption; re-assert the (process-scoped) unlock so the recovery UI can + // show and re-fetch that channel instead of leaving the stranded device unrecoverable. + if (type == FirmwareReleaseType.NIGHTLY) { + hiddenFeaturesUnlock.unlock() + } _selectedReleaseType.value = type firmwareReleaseRepository.getReleaseFlow(type).collectLatest { release -> @@ -375,7 +386,8 @@ class FirmwareUpdateViewModel( /** * Persist a [PendingFirmwareRecovery] for the current BLE nRF-DFU update, so an interrupted flash that strands the * device in bootloader mode can be recovered later. Scoped to BLE + non-ESP32 + a re-fetchable release channel - * (STABLE/ALPHA); ESP32 OTA and local-file flashes are intentionally not recoverable in this flow. + * (STABLE/ALPHA/NIGHTLY); ESP32 OTA and local-file flashes are intentionally not recoverable in this flow. A + * NIGHTLY record re-asserts the hidden-features unlock on recovery (see [enterRecoveryModeOrError]). */ private suspend fun maybeRecordRecovery(state: FirmwareUpdateState.Ready) { val type = _selectedReleaseType.value @@ -922,6 +934,7 @@ private fun isBluetoothInterfaceAddress(address: String): Boolean = private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow = when (type) { FirmwareReleaseType.STABLE -> stableRelease FirmwareReleaseType.ALPHA -> alphaRelease + FirmwareReleaseType.NIGHTLY -> nightlyRelease FirmwareReleaseType.LOCAL -> flowOf(null) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt index 67c46f83cb..bbd4731029 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt @@ -21,6 +21,7 @@ package org.meshtastic.feature.firmware import kotlinx.coroutines.test.runTest import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.model.DeviceHardware import org.meshtastic.feature.firmware.ota.FirmwareHashUtil import kotlin.test.Test @@ -226,6 +227,19 @@ abstract class CommonFirmwareRetrieverTest { ) } + @Test + fun `retrieveEsp32Firmware returns null when the zip download throws`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + + // No manifest, no direct downloads; the zip fallback fails with a transient network error + handler.zipDownloadException = IllegalStateException("connection reset") + + val result = retriever.retrieveEsp32Firmware(TEST_RELEASE, TEST_HARDWARE) {} + + assertNull(result, "A failed zip download must resolve to null, not propagate") + } + @Test fun `retrieveEsp32Firmware returns null when all strategies fail`() = runTest { val handler = FakeFirmwareFileHandler() @@ -323,6 +337,59 @@ abstract class CommonFirmwareRetrieverTest { assertEquals("firmware-CUSTOM_BOARD-2.7.17.bin", result.fileName) } + // ----------------------------------------------------------------------- + // Nightly channel (fixed firmware-nightly/ folder, no release zip) + // ----------------------------------------------------------------------- + + @Test + fun `nightly release resolves from the fixed firmware-nightly folder`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val nightly = FirmwareRelease(id = "v2.8.0.f52e2ea", zipUrl = "", releaseType = FirmwareReleaseType.NIGHTLY) + + handler.textResponses["$BASE_URL/firmware-nightly/firmware-heltec-v3-2.8.0.f52e2ea.mt.json"] = + """{"files":[{"name":"firmware-heltec-v3-2.8.0.f52e2ea.bin","md5":"","bytes":0,"part_name":"app0"}]}""" + handler.existingUrls.add("$BASE_URL/firmware-nightly/firmware-heltec-v3-2.8.0.f52e2ea.bin") + + val result = retriever.retrieveEsp32Firmware(nightly, TEST_HARDWARE) {} + + assertNotNull(result, "Nightly should resolve from firmware-nightly/, not firmware-/") + assertEquals("firmware-heltec-v3-2.8.0.f52e2ea.bin", result.fileName) + assertTrue(handler.checkedUrls.none { "firmware-2.8.0.f52e2ea/" in it }, "versioned folder must not be used") + assertTrue( + "$BASE_URL/firmware-nightly/firmware-heltec-v3-2.8.0.f52e2ea.mt.json" in handler.fetchedTextUrls, + "manifest must be fetched from firmware-nightly/", + ) + } + + @Test + fun `nightly ota zip resolves from the fixed firmware-nightly folder`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val hardware = DeviceHardware(hwModelSlug = "RAK4631", platformioTarget = "rak4631", architecture = "nrf52840") + val nightly = FirmwareRelease(id = "v2.8.0.f52e2ea", zipUrl = "", releaseType = FirmwareReleaseType.NIGHTLY) + + handler.existingUrls.add("$BASE_URL/firmware-nightly/firmware-rak4631-2.8.0.f52e2ea-ota.zip") + + val result = retriever.retrieveOtaFirmware(nightly, hardware) {} + + assertNotNull(result) + assertEquals("firmware-rak4631-2.8.0.f52e2ea-ota.zip", result.fileName) + } + + @Test + fun `nightly release without zip skips the zip fallback entirely`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val nightly = FirmwareRelease(id = "v2.8.0.f52e2ea", zipUrl = "", releaseType = FirmwareReleaseType.NIGHTLY) + + // Nothing published — every strategy fails. + val result = retriever.retrieveEsp32Firmware(nightly, TEST_HARDWARE) {} + + assertNull(result) + assertTrue(handler.downloadedUrls.isEmpty(), "no zip download may be attempted when zipUrl is blank") + } + // ----------------------------------------------------------------------- // OTA firmware (nRF52 DFU zip) // ----------------------------------------------------------------------- @@ -403,6 +470,9 @@ abstract class CommonFirmwareRetrieverTest { /** Result returned by [downloadFile] when the filename is "firmware_release.zip". */ var zipDownloadResult: FirmwareArtifact? = null + /** When set, [downloadFile] throws this for the "firmware_release.zip" download instead of returning. */ + var zipDownloadException: Exception? = null + /** Result returned by [extractFirmwareFromZip]. */ var zipExtractionResult: FirmwareArtifact? = null @@ -436,6 +506,7 @@ abstract class CommonFirmwareRetrieverTest { // Zip download path if (fileName == "firmware_release.zip") { + zipDownloadException?.let { throw it } return zipDownloadResult } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 42463a259c..483cc7bb07 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.datastore.BootloaderWarningDataSource import org.meshtastic.core.datastore.FirmwareRecoveryDataSource @@ -113,6 +114,7 @@ class FirmwareUpdateIntegrationTest { usbManager, fileHandler, TestApplicationCoroutineScope(testDispatcher), + HiddenFeaturesUnlock(), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 7b8550ee24..52f34a1b9a 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -122,6 +123,8 @@ class FirmwareUpdateViewModelTest { Dispatchers.resetMain() } + private val hiddenFeaturesUnlock = HiddenFeaturesUnlock() + private fun createViewModel() = FirmwareUpdateViewModel( firmwareReleaseRepository, deviceHardwareRepository, @@ -134,6 +137,7 @@ class FirmwareUpdateViewModelTest { usbManager, fileHandler, TestApplicationCoroutineScope(testDispatcher), + hiddenFeaturesUnlock, ) @Test @@ -444,6 +448,35 @@ class FirmwareUpdateViewModelTest { assertIs(state.updateMethod) } + @Test + fun `nightly recovery record re-asserts the hidden-features unlock`() = runTest { + // A NIGHTLY record can only have been written while unlocked; recovery after a process restart + // (unlock re-locked) must restore the unlock so the nightly channel is visible and re-fetchable. + every { radioPrefs.devAddr } returns MutableStateFlow(null) + every { firmwareReleaseRepository.nightlyRelease } returns + flowOf(FirmwareRelease(id = "v2.8.0.f52e2ea", title = "2.8.0 nightly", zipUrl = "")) + every { firmwareRecoveryDataSource.pending } returns + flowOf( + PendingFirmwareRecovery( + fullAddress = "x1234abcd", + hwModel = 1, + pioEnv = "tbeam", + releaseType = "NIGHTLY", + deviceName = "My Node", + ), + ) + + assertEquals(false, hiddenFeaturesUnlock.unlocked.value) + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.state.value + assertIs(state) + assertTrue(state.isRecovery, "Expected recovery Ready but was $state") + assertEquals(FirmwareReleaseType.NIGHTLY, viewModel.selectedReleaseType.value) + assertTrue(hiddenFeaturesUnlock.unlocked.value, "Nightly recovery must re-assert the unlock") + } + @Test fun `recovery hardware lookup failure clears stale device metadata`() = runTest { advanceUntilIdle() diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index f053915f08..fba8e1a7fe 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -135,6 +136,7 @@ class FirmwareUpdateViewModelFileTest { usbManager, fileHandler, TestApplicationCoroutineScope(testDispatcher), + HiddenFeaturesUnlock(), ) private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName") 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 6b319a6387..eb7fc33349 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 @@ -99,7 +99,7 @@ fun SettingsScreen( onBack: (() -> Unit)? = null, ) { val appFunctionsAvailable: Boolean = koinInject(qualifier = named("googleServicesAvailable")) - val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() + val hiddenFeaturesUnlocked by settingsViewModel.hiddenFeaturesUnlocked.collectAsStateWithLifecycle() val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle() val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false) @@ -322,8 +322,8 @@ fun SettingsScreen( AppInfoSection( appVersionName = settingsViewModel.appVersionName, - excludedModulesUnlocked = excludedModulesUnlocked, - onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() }, + hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, + onUnlockHiddenFeatures = { settingsViewModel.unlockHiddenFeatures() }, onShowAppIntro = { settingsViewModel.showAppIntro() }, onNavigateToAbout = { onNavigate(SettingsRoute.About) }, ) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt index 90636e1d86..fef5059c13 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt @@ -58,8 +58,8 @@ import kotlin.time.Duration.Companion.seconds @Composable fun AppInfoSection( appVersionName: String, - excludedModulesUnlocked: Boolean, - onUnlockExcludedModules: () -> Unit, + hiddenFeaturesUnlocked: Boolean, + onUnlockHiddenFeatures: () -> Unit, onShowAppIntro: () -> Unit, onNavigateToAbout: () -> Unit, ) { @@ -107,9 +107,9 @@ fun AppInfoSection( } AppVersionButton( - excludedModulesUnlocked = excludedModulesUnlocked, + hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, appVersionName = appVersionName, - onUnlockExcludedModules = onUnlockExcludedModules, + onUnlockHiddenFeatures = onUnlockHiddenFeatures, ) } } @@ -120,9 +120,9 @@ private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the @Composable private fun AppVersionButton( - excludedModulesUnlocked: Boolean, + hiddenFeaturesUnlocked: Boolean, appVersionName: String, - onUnlockExcludedModules: () -> Unit, + onUnlockHiddenFeatures: () -> Unit, ) { val scope = rememberCoroutineScope() val context = LocalContext.current @@ -144,14 +144,14 @@ private fun AppVersionButton( clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT) when { - clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> { + clickCount == UNLOCKED_CLICK_COUNT && hiddenFeaturesUnlocked -> { clickCount = 0 scope.launch { context.showToast(Res.string.modules_already_unlocked) } } clickCount == UNLOCK_CLICK_COUNT -> { clickCount = 0 - onUnlockExcludedModules() + onUnlockHiddenFeatures() scope.launch { context.showToast(Res.string.modules_unlocked) } } } @@ -164,8 +164,8 @@ fun AppInfoSectionPreview() { AppTheme { AppInfoSection( appVersionName = "2.5.0", - excludedModulesUnlocked = false, - onUnlockExcludedModules = {}, + hiddenFeaturesUnlocked = false, + onUnlockHiddenFeatures = {}, onShowAppIntro = {}, onNavigateToAbout = {}, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt index 7701f71925..b279604a78 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt @@ -43,7 +43,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel @Composable fun ModuleConfigurationScreen( viewModel: RadioConfigViewModel, - excludedModulesUnlocked: Boolean, + hiddenFeaturesUnlocked: Boolean, onBack: () -> Unit, onNavigate: (Route) -> Unit, ) { @@ -52,8 +52,8 @@ fun ModuleConfigurationScreen( val deviceRole = state.radioConfig.device?.role val modules = - remember(state.metadata, deviceRole, excludedModulesUnlocked) { - if (excludedModulesUnlocked) { + remember(state.metadata, deviceRole, hiddenFeaturesUnlocked) { + if (hiddenFeaturesUnlocked) { ModuleRoute.entries } else { ModuleRoute.filterExcludedFrom(state.metadata, deviceRole) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 1c846f1b2a..825edefe43 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -24,11 +24,11 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase @@ -63,6 +63,7 @@ class SettingsViewModel( private val exportDataUseCase: ExportDataUseCase, private val isOtaCapableUseCase: IsOtaCapableUseCase, private val fileService: FileService, + private val hiddenFeaturesUnlock: HiddenFeaturesUnlock, ) : ViewModel() { val myNodeInfo: StateFlow = nodeRepository.myNodeInfo @@ -99,8 +100,8 @@ class SettingsViewModel( radioController.stopProvideLocation() } - private val _excludedModulesUnlocked = MutableStateFlow(false) - val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow() + // Process-scoped shared state so other features (e.g. the nightly firmware channel) see the same unlock. + val hiddenFeaturesUnlocked: StateFlow = hiddenFeaturesUnlock.unlocked val appVersionName get() = buildConfigProvider.versionName @@ -159,8 +160,8 @@ class SettingsViewModel( uiPrefs.setAppIntroCompleted(false) } - fun unlockExcludedModules() { - _excludedModulesUnlocked.update { true } + fun unlockHiddenFeatures() { + hiddenFeaturesUnlock.unlock() } /** 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 31609ba0ba..b17ac51b9b 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 @@ -108,10 +108,10 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { entry { val settingsViewModel: SettingsViewModel = koinViewModel() - val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() + val hiddenFeaturesUnlocked by settingsViewModel.hiddenFeaturesUnlocked.collectAsStateWithLifecycle() ModuleConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), - excludedModulesUnlocked = excludedModulesUnlocked, + hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index f39ee49197..1434f4cc66 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -43,6 +43,7 @@ import okio.Buffer import okio.BufferedSink import okio.ByteString.Companion.encodeUtf8 import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase @@ -120,6 +121,7 @@ class SettingsViewModelTest { exportDataUseCase = exportDataUseCase, isOtaCapableUseCase = isOtaCapableUseCase, fileService = fileService, + hiddenFeaturesUnlock = HiddenFeaturesUnlock(), ) } @@ -180,10 +182,10 @@ class SettingsViewModelTest { } @Test - fun `unlockExcludedModules updates state`() = runTest { - viewModel.excludedModulesUnlocked.value shouldBe false - viewModel.unlockExcludedModules() - viewModel.excludedModulesUnlocked.value shouldBe true + fun `unlockHiddenFeatures updates state`() = runTest { + viewModel.hiddenFeaturesUnlocked.value shouldBe false + viewModel.unlockHiddenFeatures() + viewModel.hiddenFeaturesUnlocked.value shouldBe true } @Test diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 8e6188956f..647c449719 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -104,7 +104,7 @@ fun DesktopSettingsScreen( val destNode by radioConfigViewModel.destNode.collectAsStateWithLifecycle() val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle() val homoglyphEnabled by radioConfigViewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false) - val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() + val hiddenFeaturesUnlocked by settingsViewModel.hiddenFeaturesUnlocked.collectAsStateWithLifecycle() val cacheLimit by settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle() val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle() @@ -259,8 +259,8 @@ fun DesktopSettingsScreen( DesktopAppInfoSection( appVersionName = settingsViewModel.appVersionName, - excludedModulesUnlocked = excludedModulesUnlocked, - onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() }, + hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, + onUnlockHiddenFeatures = { settingsViewModel.unlockHiddenFeatures() }, onNavigateToAbout = { onNavigate(SettingsRoute.About) }, ) } @@ -268,12 +268,12 @@ fun DesktopSettingsScreen( } } -/** Desktop App Info section: About link and version with excluded-modules unlock easter egg. */ +/** Desktop App Info section: About link and version with hidden-features unlock easter egg. */ @Composable private fun DesktopAppInfoSection( appVersionName: String, - excludedModulesUnlocked: Boolean, - onUnlockExcludedModules: () -> Unit, + hiddenFeaturesUnlocked: Boolean, + onUnlockHiddenFeatures: () -> Unit, onNavigateToAbout: () -> Unit, ) { ExpressiveSection(title = stringResource(Res.string.info)) { @@ -286,9 +286,9 @@ private fun DesktopAppInfoSection( } DesktopAppVersionButton( - excludedModulesUnlocked = excludedModulesUnlocked, + hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, appVersionName = appVersionName, - onUnlockExcludedModules = onUnlockExcludedModules, + onUnlockHiddenFeatures = onUnlockHiddenFeatures, ) } } @@ -299,9 +299,9 @@ private const val UNLOCK_TIMEOUT_SECONDS = 1 @Composable private fun DesktopAppVersionButton( - excludedModulesUnlocked: Boolean, + hiddenFeaturesUnlocked: Boolean, appVersionName: String, - onUnlockExcludedModules: () -> Unit, + onUnlockHiddenFeatures: () -> Unit, ) { val scope = rememberCoroutineScope() val showToast = rememberShowToastResource() @@ -323,14 +323,14 @@ private fun DesktopAppVersionButton( clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT) when { - clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> { + clickCount == UNLOCKED_CLICK_COUNT && hiddenFeaturesUnlocked -> { clickCount = 0 scope.launch { showToast(Res.string.modules_already_unlocked) } } clickCount == UNLOCK_CLICK_COUNT -> { clickCount = 0 - onUnlockExcludedModules() + onUnlockHiddenFeatures() scope.launch { showToast(Res.string.modules_unlocked) } } }