mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-08 19:41:30 -04:00
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:
1 parent
b7b100ee97
commit
5b0d046d42
27 files changed
+411
-69
No files matched your search
Generated
+1
@@ -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
|
||||
|
||||
+37
@@ -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
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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 {
|
||||
|
||||
+3
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+55
-1
@@ -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.
|
||||
|
||||
+3
@@ -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 = "",
|
||||
)
|
||||
}
|
||||
+5
@@ -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()
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -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)) {
|
||||
|
||||
+21
-21
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
@@ -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()
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+3
-3
@@ -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) },
|
||||
)
|
||||
|
||||
+10
-10
@@ -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 = {},
|
||||
)
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+6
-5
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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) },
|
||||
)
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+12
-12
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user