feat(firmware): nightly preview channel behind the hidden-features unlock (#6225)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 authored and GitHub committed 2026-07-11 19:41:19 +00:00
1 parent b7b100ee97
commit 5b0d046d42
27 files changed
+411 -69

No files matched your search

+1
View File
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = _unlocked.asStateFlow()
fun unlock() {
_unlocked.value = true
}
}
@@ -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<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
override val nightlyRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.NIGHTLY)
private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow<FirmwareRelease?> = 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 {
@@ -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")
}
@@ -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
@@ -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<NetworkDeviceHardware> = 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.
@@ -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,
}
@@ -39,3 +39,33 @@ data class NetworkFirmwareReleases(
@SerialName("pullRequests") val pullRequests: List<NetworkFirmwareRelease> = 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 = "",
)
}
@@ -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() }
}
@@ -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<NetworkFirmwareNightly>(response.bodyAsText())
else -> error("Unexpected HTTP ${response.status} fetching nightly firmware index")
}
}
override suspend fun getEventFirmware(): EventFirmwareResponse = client.get("resource/eventFirmware").body()
}
@@ -26,6 +26,12 @@ interface FirmwareReleaseRepository {
/** A flow that provides the latest ALPHA firmware release. */
val alphaRelease: Flow<FirmwareRelease?>
/**
* 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<FirmwareRelease?>
/** Invalidates the local cache of firmware releases. */
suspend fun invalidateCache()
}
@@ -665,6 +665,7 @@
<string name="firmware_update_method_usb">USB File Transfer</string>
<string name="firmware_update_method_wifi">WiFi OTA</string>
<string name="firmware_update_missing_target">Firmware target information is unavailable for this device.</string>
<string name="firmware_update_nightly">Nightly</string>
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
@@ -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<FirmwareRelease?>(null)
private val _alphaRelease = mutableStateFlow<FirmwareRelease?>(null)
private val _nightlyRelease = mutableStateFlow<FirmwareRelease?>(null)
override val stableRelease: Flow<FirmwareRelease?> = _stableRelease
override val alphaRelease: Flow<FirmwareRelease?> = _alphaRelease
override val nightlyRelease: Flow<FirmwareRelease?> = _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
}
}
@@ -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)) {
@@ -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))
}
}
}
}
@@ -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>(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<Boolean> = hiddenFeaturesUnlock.unlocked
private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE)
val selectedReleaseType: StateFlow<FirmwareReleaseType> = _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<FirmwareRelease?> = when (type) {
FirmwareReleaseType.STABLE -> stableRelease
FirmwareReleaseType.ALPHA -> alphaRelease
FirmwareReleaseType.NIGHTLY -> nightlyRelease
FirmwareReleaseType.LOCAL -> flowOf(null)
}
@@ -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-<version>/")
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
}
@@ -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
@@ -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<FirmwareUpdateMethod.Ble>(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<FirmwareUpdateState.Ready>(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()
@@ -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")
@@ -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) },
)
@@ -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 = {},
)
@@ -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)
@@ -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<MyNodeInfo?> = nodeRepository.myNodeInfo
@@ -99,8 +100,8 @@ class SettingsViewModel(
radioController.stopProvideLocation()
}
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
// Process-scoped shared state so other features (e.g. the nightly firmware channel) see the same unlock.
val hiddenFeaturesUnlocked: StateFlow<Boolean> = 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()
}
/**
@@ -108,10 +108,10 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoute.ModuleConfiguration> {
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) },
)
@@ -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
@@ -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) }
}
}