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) }
}
}