feat: wire up event-firmware metadata API (offline-first) + post-event firmware reminder (#6162)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-08 18:53:32 -05:00
committed by GitHub
parent 8e8cc5b518
commit 9e55cdb3eb
23 changed files with 2537 additions and 199 deletions

View File

@@ -79,6 +79,35 @@ jobs:
fi
fi
- name: Update event firmware metadata
id: event_firmware
run: |
event_firmware_file_path="androidApp/src/main/assets/event_firmware.json"
temp_event_firmware_file="/tmp/new_event_firmware.json"
echo "Fetching latest event firmware metadata..."
http_code=$(curl -s --max-time 90 -o "$temp_event_firmware_file" -w '%{http_code}' https://api.meshtastic.org/resource/eventFirmware || true)
http_code="${http_code:-0}"
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "::warning::Event firmware API returned HTTP $http_code. Skipping event firmware update."
echo "status=error" >> "$GITHUB_OUTPUT"
echo "detail=HTTP $http_code from event firmware API" >> "$GITHUB_OUTPUT"
elif ! jq empty "$temp_event_firmware_file" 2>/dev/null; then
echo "::warning::Event firmware API returned invalid JSON data. Skipping event firmware update."
echo "status=error" >> "$GITHUB_OUTPUT"
echo "detail=Invalid JSON response from event firmware API" >> "$GITHUB_OUTPUT"
else
if [ ! -f "$event_firmware_file_path" ] || ! jq --sort-keys . "$temp_event_firmware_file" | diff -q - <(jq --sort-keys . "$event_firmware_file_path"); then
echo "Changes detected in event firmware metadata or local file missing. Updating $event_firmware_file_path."
cp "$temp_event_firmware_file" "$event_firmware_file_path"
echo "status=updated" >> "$GITHUB_OUTPUT"
else
echo "No changes detected in event firmware metadata."
echo "status=unchanged" >> "$GITHUB_OUTPUT"
fi
fi
- name: Sync with Crowdin
uses: crowdin/github-action@v2
with:
@@ -113,11 +142,22 @@ jobs:
- name: Build PR body
id: pr_body
env:
# Hoisted into env: (not interpolated directly in the script) so no step output can inject shell — see
# zizmor template-injection. Values are fixed strings today, but detail may carry API text in future.
FIRMWARE_STATUS: ${{ steps.firmware.outputs.status }}
FIRMWARE_DETAIL: ${{ steps.firmware.outputs.detail }}
HARDWARE_STATUS: ${{ steps.hardware.outputs.status }}
HARDWARE_DETAIL: ${{ steps.hardware.outputs.detail }}
EVENT_FIRMWARE_STATUS: ${{ steps.event_firmware.outputs.status }}
EVENT_FIRMWARE_DETAIL: ${{ steps.event_firmware.outputs.detail }}
run: |
firmware_status="${{ steps.firmware.outputs.status }}"
firmware_detail="${{ steps.firmware.outputs.detail }}"
hardware_status="${{ steps.hardware.outputs.status }}"
hardware_detail="${{ steps.hardware.outputs.detail }}"
firmware_status="$FIRMWARE_STATUS"
firmware_detail="$FIRMWARE_DETAIL"
hardware_status="$HARDWARE_STATUS"
hardware_detail="$HARDWARE_DETAIL"
event_firmware_status="$EVENT_FIRMWARE_STATUS"
event_firmware_detail="$EVENT_FIRMWARE_DETAIL"
body="This PR includes automated updates from the scheduled workflow:"
body+=$'\n'
@@ -138,6 +178,14 @@ jobs:
*) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;;
esac
# Event firmware status
case "$event_firmware_status" in
updated) body+=$'\n'"- ✅ \`event_firmware.json\` updated from the Meshtastic API." ;;
unchanged) body+=$'\n'"- ✔️ \`event_firmware.json\` checked — no changes detected." ;;
error) body+=$'\n'"- ⚠️ \`event_firmware.json\` skipped — ${event_firmware_detail}." ;;
*) body+=$'\n'"- ❓ \`event_firmware.json\` — unknown status." ;;
esac
# Crowdin (always attempted)
body+=$'\n'"- Source strings were uploaded to Crowdin."
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
@@ -161,6 +209,7 @@ jobs:
Automated updates for:
- Firmware releases list
- Device hardware list
- Event firmware metadata
- Crowdin source string uploads
- Crowdin translation downloads
title: 'chore: Scheduled updates (Firmware, Hardware, Translations)'
@@ -171,6 +220,7 @@ jobs:
add-paths: |
androidApp/src/main/assets/firmware_releases.json
androidApp/src/main/assets/device_hardware.json
androidApp/src/main/assets/event_firmware.json
fastlane/metadata/android/**
**/strings.xml
docs/**/*.md

View File

@@ -582,6 +582,8 @@ firebase_link
### FIRMWARE ###
firmware
firmware_edition
firmware_event_ended_banner
firmware_event_ended_button
firmware_old
firmware_recovery_banner
firmware_recovery_ble_failed

View File

@@ -18,6 +18,7 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
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.NetworkFirmwareReleases
@@ -42,5 +43,8 @@ class FDroidNetworkModule {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
throw UnsupportedOperationException("getFirmwareReleases is not supported on F-Droid builds.")
override suspend fun getEventFirmware(): EventFirmwareResponse =
throw UnsupportedOperationException("getEventFirmware is not supported on F-Droid builds.")
}
}

View File

@@ -1,63 +1,183 @@
{
"version": 1,
"generatedAt": "2026-06-23T00:00:00Z",
"version": 2,
"generatedAt": "2026-06-30T00:00:00Z",
"source": "bundled",
"editions": [
{
"edition": "HAMVENTION",
"displayName": "Hamvention",
"displayName": "Dayton Hamvention 2026",
"welcomeMessage": "Welcome to Hamvention! 🍖📻",
"tag": "Hamvention",
"eventStart": "2026-05-15",
"eventEnd": "2026-05-17",
"timeZone": "America/New_York",
"location": "Xenia, Ohio, USA",
"iconUrl": null,
"accentColor": "#005DAA",
"iconUrl": "https://api.meshtastic.org/resource/eventFirmware/hamvention.png",
"accentColor": "#BF1E2E",
"domain": "hamvention.meshtastic.org",
"links": [
{ "label": "Event Website", "url": "https://hamvention.org" }
]
{
"label": "Event Website",
"url": "https://hamvention.org"
}
],
"theme": {
"name": "Radio Adventure",
"tagline": "Exploring the many avenues amateur radio has to offer.",
"colors": {
"primary": "#BF1E2E",
"secondary": null,
"accent": null
},
"palette": [
"#BF1E2E"
],
"fonts": null
},
"firmware": {
"slug": "dayton2026",
"version": "2.7.23.07741e6",
"id": "v2.7.23.07741e6",
"title": "Meshtastic Firmware 2.7.23.07741e6",
"zipUrl": "https://github.com/meshtastic/meshtastic.github.io/raw/master/event/dayton2026/firmware-2.7.23.07741e6.zip",
"releaseNotes": "## Welcome to Dayton Hamvention 2026!\n\nThis firmware has been customized for Hamvention with factory default configurations.\n\n### ⚠️ Important: Backup Before Flashing\n\nIf your device has existing settings or encryption keys, **backup your keys / configurations** before proceeding. Flashing will reset your device to factory settings for the event.\n\n### Quick Start\n\n1. Ensure a **data-capable USB cable** is connected\n2. Select your device type\n3. Choose \"Full Erase and Install\"\n4. After flashing, download the Meshtastic app and pair via Bluetooth\n5. If you updated from a previous version or installed a UF2 on an NRF52 device, you will need to perform a factory reset on the device to activate the Hamvention mode.\n\n**73 and happy meshing from Dayton!**"
}
},
{
"edition": "OPEN_SAUCE",
"displayName": "Open Sauce",
"displayName": "Open Sauce 2026",
"welcomeMessage": "Welcome to Open Sauce! 🔧",
"eventStart": "2026-07-18",
"tag": "Open Sauce",
"eventStart": "2026-07-17",
"eventEnd": "2026-07-19",
"timeZone": "America/Los_Angeles",
"location": "San Mateo, California, USA",
"iconUrl": null,
"accentColor": "#E94F1D",
"domain": "opensauce.meshtastic.org",
"links": [
{ "label": "Event Website", "url": "https://opensauce.com" }
]
{
"label": "Event Website",
"url": "https://opensauce.com"
}
],
"theme": {
"name": "Makers & Mesh",
"tagline": "Where makers, science, and mesh collide.",
"colors": {
"primary": "#E94F1D",
"secondary": null,
"accent": null
},
"palette": [
"#E94F1D"
],
"fonts": null
},
"firmware": {
"slug": "opensauce2026",
"version": "2.7.26.004b486",
"id": "v2.7.26.004b486",
"title": "Meshtastic Firmware 2.7.26.004b486",
"zipUrl": "https://github.com/meshtastic/meshtastic.github.io/raw/master/event/opensauce2026/firmware-2.7.26.004b486.zip",
"releaseNotes": "## Welcome to Open Sauce 2026! 🔧\n\nThis firmware has been customized for Open Sauce with factory default configurations.\n\n### ⚠️ Important: Backup Before Flashing\n\nIf your device has existing settings or encryption keys, **backup your keys / configurations** before proceeding. Flashing will reset your device to factory settings for the event.\n\n### Quick Start\n\n1. Ensure a **data-capable USB cable** is connected\n2. Select your device type\n3. Choose \"Full Erase and Install\"\n4. After flashing, download the Meshtastic app and pair via Bluetooth\n5. If you updated from a previous version or installed a UF2 on an NRF52 device, you will need to perform a factory reset on the device to activate the Open Sauce mode.\n\n**Happy meshing from San Mateo!**"
}
},
{
"edition": "DEFCON",
"displayName": "DEFCON",
"welcomeMessage": "Welcome to DEFCON! 💀",
"displayName": "DEF CON 34",
"welcomeMessage": "Welcome to DEF CON 34! 💀",
"tag": "DEFCON",
"eventStart": "2026-08-06",
"eventEnd": "2026-08-09",
"timeZone": "America/Los_Angeles",
"location": "Las Vegas, Nevada, USA",
"location": "Las Vegas Convention Center, Las Vegas, Nevada, USA",
"iconUrl": null,
"accentColor": "#C0392B",
"accentColor": "#0D294A",
"domain": "defcon.meshtastic.org",
"links": [
{ "label": "Event Website", "url": "https://defcon.org" }
]
{
"label": "Event Website",
"url": "https://defcon.org"
},
{
"label": "DEF CON 34 Theme",
"url": "https://defcon.org/html/defcon-34/dc-34-theme.html"
},
{
"label": "Mastodon",
"url": "https://defcon.social"
}
],
"theme": {
"name": "Agency",
"tagline": "Agency is self-determination. It's about making choices that increase yours, AND helping others to control theirs.",
"colors": {
"primary": "#0D294A",
"secondary": "#017FA4",
"accent": "#E0004E"
},
"palette": [
"#0D294A",
"#017FA4",
"#105F66",
"#6CCDB8",
"#F1B435",
"#E0004E"
],
"fonts": {
"heading": "Lato",
"body": "Atkinson Hyperlegible"
}
},
"firmware": {
"slug": "defcon2026",
"version": null,
"id": null,
"title": null,
"zipUrl": null,
"releaseNotes": null
}
},
{
"edition": "BURNING_MAN",
"displayName": "Burning Man",
"displayName": "Burning Man 2026",
"welcomeMessage": "Welcome to Burning Man! 🔥",
"tag": "Burning Man",
"eventStart": "2026-08-30",
"eventEnd": "2026-09-07",
"timeZone": "America/Los_Angeles",
"location": "Black Rock City, Nevada, USA",
"iconUrl": null,
"accentColor": "#E8552D",
"accentColor": "#EC8819",
"domain": "burningman.meshtastic.org",
"links": [
{ "label": "Event Website", "url": "https://burningman.org" }
]
{
"label": "Event Website",
"url": "https://burningman.org"
}
],
"theme": {
"name": "Axis Mundi",
"tagline": "The axis of the world — a celestial column connecting earth, sky, and the realms between.",
"colors": {
"primary": "#EC8819",
"secondary": null,
"accent": null
},
"palette": [
"#EC8819"
],
"fonts": null
},
"firmware": {
"slug": "burningman2026",
"version": null,
"id": null,
"title": null,
"zipUrl": null,
"releaseNotes": null
}
}
]
}

View File

@@ -0,0 +1,47 @@
/*
* 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.data.datasource
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class EventFirmwareEditionLocalDataSource(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
private val dao
get() = dbManager.currentDb.value.eventFirmwareEditionDao()
suspend fun getByEdition(edition: String): EventFirmwareEditionEntity? =
withContext(dispatchers.io) { dao.getByEdition(edition) }
suspend fun upsertAll(editions: List<EventFirmwareEditionEntity>) =
withContext(dispatchers.io) { dao.upsertAll(editions) }
// No-op on empty: `NOT IN ()` is always true in SQLite, so forwarding an empty list would wipe the whole table.
// Callers only prune against a non-empty upstream payload; an empty list means "keep everything".
suspend fun deleteNotIn(keep: List<String>) {
if (keep.isEmpty()) return
withContext(dispatchers.io) { dao.deleteNotIn(keep) }
}
suspend fun count(): Int = withContext(dispatchers.io) { dao.count() }
}

View File

@@ -17,57 +17,130 @@
package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource
import org.meshtastic.core.data.datasource.decode
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.EventFirmwareRemoteDataSource
import org.meshtastic.core.repository.EventFirmwareRepository
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.minutes
/**
* ponytail: bundled-only — the snapshot is a handful of static records, so it's decoded once into memory with no DB or
* network refresh (unlike [DeviceLinkRepositoryImpl]). Add a `/resource/eventFirmware` refresh when that API ships.
* Caches event-firmware display metadata in the DB, seeded from the bundled `event_firmware.json` snapshot and
* refreshed from `/resource/eventFirmware`. DB-backed (not just in-memory) so the snapshot survives across process
* restarts without waiting on a fresh network round-trip. Mirrors [DeviceHardwareRepositoryImpl]'s seed → single-flight
* refresh pattern.
*/
@Single
class EventFirmwareRepositoryImpl(
private val remoteDataSource: EventFirmwareRemoteDataSource,
private val assetReader: BundledAssetReader,
private val json: Json,
private val localDataSource: EventFirmwareEditionLocalDataSource,
private val dispatchers: CoroutineDispatchers,
) : EventFirmwareRepository {
private val mutex = Mutex()
/** Serializes seeding so concurrent callers don't duplicate writes. */
private val writeMutex = Mutex()
@Volatile private var cache: Map<String, EventFirmwareEdition>? = null
@Volatile private var lastRefreshMillis = 0L
override suspend fun getEdition(editionName: String): EventFirmwareEdition? = load()[editionName]
// Advanced on every refresh *attempt* (success, empty, or failure), unlike lastRefreshMillis which only advances on
// a stored non-empty payload. Without this, an offline device (a common, expected state) would re-enter the stale
// branch and block up to NETWORK_REFRESH_TIMEOUT_MS on every getEdition() call, since the failed Deferred is no
// longer active. The cooldown caps retries while offline without waiting the full CACHE_EXPIRATION_TIME_MS.
@Volatile private var lastAttemptMillis = 0L
/** Guards [inFlightRefresh] so concurrent callers share one network fetch. */
private val refreshGuard = Mutex()
private var inFlightRefresh: Deferred<Unit>? = null
// This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled. The refresh runs
// here so a caller that stops waiting can't abort the shared fetch — api.meshtastic.org has been measured
// taking 20-60s, and the fetch should still land in the DB for the next lookup.
private val refreshScope = CoroutineScope(dispatchers.io + SupervisorJob())
override suspend fun getEdition(editionName: String): EventFirmwareEdition? = withContext(dispatchers.io) {
ensureSeeded()
val stale = nowMillis - lastRefreshMillis > CACHE_EXPIRATION_TIME_MS
val retryCooldownElapsed = nowMillis - lastAttemptMillis > REFRESH_RETRY_COOLDOWN_MS
if (stale && retryCooldownElapsed) {
singleFlightRefresh(maxWaitMs = NETWORK_REFRESH_TIMEOUT_MS)
}
localDataSource.getByEdition(editionName)?.asExternalModel()
}
/** Seeds the table from the bundled snapshot if empty (fresh install, data clear). */
private suspend fun ensureSeeded() {
if (localDataSource.count() > 0) return
writeMutex.withLock {
if (localDataSource.count() == 0) {
safeCatching {
val editions = assetReader.decode<EventFirmwareResponse>(ASSET_NAME, json)?.editions.orEmpty()
localDataSource.upsertAll(editions.map { it.asEntity() })
}
.onFailure { e -> Logger.w(e) { "EventFirmwareRepository: failed to seed from bundled JSON" } }
}
}
}
/**
* Decodes the bundled snapshot once, keyed by edition name. Empty (not crashing) if the asset is absent/malformed.
* Starts (or joins) a single shared refresh running in [refreshScope]. The caller waits at most [maxWaitMs] before
* falling back to cached data; the refresh itself always runs to completion, bounded only by the HttpClient's own
* timeout/retry policy.
*/
private suspend fun load(): Map<String, EventFirmwareEdition> {
cache?.let {
return it
}
return mutex.withLock {
cache
?: withContext(dispatchers.io) {
safeCatching { assetReader.decode<EventFirmwareResponse>(ASSET_NAME, json)?.editions.orEmpty() }
.onFailure { e -> Logger.w(e) { "EventFirmwareRepository: failed to read bundled JSON" } }
.getOrDefault(emptyList())
.associateBy { it.edition }
}
.also { cache = it }
private suspend fun singleFlightRefresh(maxWaitMs: Long) {
val refresh =
refreshGuard.withLock {
inFlightRefresh?.takeIf { it.isActive }
?: refreshScope
.async {
lastAttemptMillis = nowMillis
safeCatching {
val editions = remoteDataSource.getEventFirmware().editions
if (editions.isNotEmpty()) {
localDataSource.upsertAll(editions.map { it.asEntity() })
localDataSource.deleteNotIn(editions.map { it.edition })
lastRefreshMillis = nowMillis
}
}
.onFailure { e -> Logger.w(e) { "EventFirmwareRepository: network refresh failed" } }
Unit
}
.also { inFlightRefresh = it }
}
if (withTimeoutOrNull(maxWaitMs) { refresh.join() } == null) {
Logger.w { "EventFirmwareRepository: refresh still in flight after ${maxWaitMs}ms; using cached data" }
}
}
private companion object {
private const val ASSET_NAME = "event_firmware.json"
private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_DAY.inWholeMilliseconds
/** Minimum gap between refresh *attempts*, so a failing/empty fetch (e.g. offline) doesn't retry every call. */
private val REFRESH_RETRY_COOLDOWN_MS = 5.minutes.inWholeMilliseconds
/** Maximum time a blocking lookup waits for an in-flight refresh before returning cached/bundled data. */
private const val NETWORK_REFRESH_TIMEOUT_MS = 5_000L
}
}

View File

@@ -24,6 +24,7 @@ import okio.Source
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkDeviceLinksResponse
@@ -46,6 +47,8 @@ class DeviceLinkRepositoryImplTest {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = response
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
}
/** Serves only `device_links.json`, serializing the current [links] so the repo seeds via the real decode path. */

View File

@@ -22,73 +22,203 @@ import kotlinx.serialization.json.Json
import okio.Buffer
import okio.Source
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareBuild
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.model.EventFirmwareResponse
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.NetworkFirmwareReleases
import org.meshtastic.core.network.EventFirmwareRemoteDataSource
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.testing.FakeDatabaseProvider
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class EventFirmwareRepositoryImplTest {
/** Only [getEventFirmware] is exercised; the other endpoints are never called by this repository. */
private class FakeApiService(var response: EventFirmwareResponse) : ApiService {
var eventFirmwareCalls = 0
private set
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = error("unused")
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused")
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
override suspend fun getEventFirmware(): EventFirmwareResponse {
eventFirmwareCalls++
return response
}
}
/** Serves only `event_firmware.json`, serializing [editions] so the repo decodes via the real path. */
private class FakeBundledAssetReader(
var editions: List<EventFirmwareEdition>,
private val json: Json,
var present: Boolean = true,
) : BundledAssetReader {
var opens = 0
private set
override fun open(name: String): Source? {
if (name != "event_firmware.json" || !present) return null
opens++
val bytes = json.encodeToString(EventFirmwareResponse(editions = editions)).encodeToByteArray()
return Buffer().write(bytes)
}
}
private val json = Json { ignoreUnknownKeys = true }
// Real dispatchers + runBlocking (per test), NOT runTest/UnconfinedTestDispatcher — see the comment in
// DeviceLinkRepositoryImplTest for why runTest's virtual clock flakes the withTimeoutOrNull-bounded refresh.
private val unconfined = Dispatchers.Unconfined
private val dispatchers = CoroutineDispatchers(main = unconfined, io = unconfined, default = unconfined)
private lateinit var dbProvider: FakeDatabaseProvider
private lateinit var local: EventFirmwareEditionLocalDataSource
private lateinit var api: FakeApiService
private lateinit var seed: FakeBundledAssetReader
private lateinit var repository: EventFirmwareRepositoryImpl
private fun edition(name: String) =
EventFirmwareEdition(edition = name, displayName = name.lowercase(), welcomeMessage = "hi $name")
@BeforeTest
fun setup() {
dbProvider = FakeDatabaseProvider()
local = EventFirmwareEditionLocalDataSource(dbProvider, dispatchers)
api = FakeApiService(EventFirmwareResponse())
seed = FakeBundledAssetReader(emptyList(), json)
repository =
EventFirmwareRepositoryImpl(
remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers),
assetReader = seed,
json = json,
localDataSource = local,
dispatchers = dispatchers,
)
}
@AfterTest fun tearDown() = dbProvider.close()
@Test
fun getEditionReturnsMatchingRecordByEnumName() = runBlocking {
val reader = FakeBundledAssetReader(listOf(edition("HAMVENTION"), edition("DEFCON")), json)
val repo = EventFirmwareRepositoryImpl(reader, json, dispatchers)
seed.editions = listOf(edition("HAMVENTION"), edition("DEFCON"))
assertEquals("hamvention", repo.getEdition("HAMVENTION")?.displayName)
assertEquals("hi DEFCON", repo.getEdition("DEFCON")?.welcomeMessage)
assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName)
assertEquals("hi DEFCON", repository.getEdition("DEFCON")?.welcomeMessage)
}
@Test
fun getEditionReturnsNullForUnknownEdition() = runBlocking {
val repo =
EventFirmwareRepositoryImpl(FakeBundledAssetReader(listOf(edition("HAMVENTION")), json), json, dispatchers)
seed.editions = listOf(edition("HAMVENTION"))
assertNull(repo.getEdition("VANILLA"))
assertNull(repository.getEdition("VANILLA"))
}
@Test
fun absentAssetYieldsNullWithoutCrashing() = runBlocking {
val repo =
EventFirmwareRepositoryImpl(FakeBundledAssetReader(emptyList(), json, present = false), json, dispatchers)
seed.present = false
assertNull(repo.getEdition("HAMVENTION"))
assertNull(repository.getEdition("HAMVENTION"))
}
@Test
fun snapshotIsDecodedOnlyOnceAndCached() = runBlocking {
val reader = FakeBundledAssetReader(listOf(edition("HAMVENTION")), json)
val repo = EventFirmwareRepositoryImpl(reader, json, dispatchers)
fun seedsFromBundledJsonOnlyWhenTableEmpty() = runBlocking {
seed.editions = listOf(edition("HAMVENTION"))
repository.getEdition("HAMVENTION")
assertEquals(1, local.count())
repo.getEdition("HAMVENTION")
repo.getEdition("DEFCON")
repo.getEdition("HAMVENTION")
// A larger snapshot must NOT re-seed once the table is populated.
seed.editions = seed.editions + edition("DEFCON")
repository.getEdition("DEFCON")
assertEquals(1, local.count())
}
assertEquals(1, reader.opens)
@Test
fun networkRefreshReplacesBundledSnapshot() = runBlocking {
seed.editions = listOf(edition("HAMVENTION"))
api.response = EventFirmwareResponse(editions = listOf(edition("DEFCON")))
// First call is always stale (never refreshed yet), so it triggers + awaits the network fetch.
assertNull(repository.getEdition("HAMVENTION"))
assertEquals("hi DEFCON", repository.getEdition("DEFCON")?.welcomeMessage)
}
@Test
fun emptyNetworkResponseLeavesBundledSnapshotInPlace() = runBlocking {
seed.editions = listOf(edition("HAMVENTION"))
api.response = EventFirmwareResponse(editions = emptyList())
assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName)
}
@Test
fun failedRefreshDoesNotRetryOnEveryCall() = runBlocking {
// Empty response never advances the success timestamp; without the retry cooldown the second call would
// re-enter the stale branch and fetch again. The cooldown (minutes) means a second immediate call skips it.
seed.editions = listOf(edition("HAMVENTION"))
api.response = EventFirmwareResponse(editions = emptyList())
repository.getEdition("HAMVENTION")
repository.getEdition("HAMVENTION")
assertEquals(1, api.eventFirmwareCalls)
}
@Test
fun v2FieldsRoundTripThroughCache() = runBlocking {
seed.editions =
listOf(
EventFirmwareEdition(
edition = "HAMVENTION",
displayName = "Dayton Hamvention 2026",
welcomeMessage = "hi",
tag = "Hamvention",
domain = "hamvention.meshtastic.org",
theme =
EventFirmwareTheme(
name = "Radio Adventure",
palette = listOf("#BF1E2E"),
colors = EventFirmwareThemeColors(primary = "#BF1E2E"),
),
firmware = EventFirmwareBuild(version = "2.7.23.07741e6", zipUrl = "https://example/f.zip"),
),
)
val got = repository.getEdition("HAMVENTION")!!
assertEquals("Hamvention", got.tag)
assertEquals("hamvention.meshtastic.org", got.domain)
assertEquals("Radio Adventure", got.theme?.name)
assertEquals("#BF1E2E", got.theme?.colors?.primary)
assertEquals(listOf("#BF1E2E"), got.theme?.palette)
assertEquals("2.7.23.07741e6", got.firmware?.version)
assertEquals("https://example/f.zip", got.firmware?.zipUrl)
}
@Test
fun persistsAcrossRepositoryInstancesBackedByTheSameDb() = runBlocking {
seed.editions = listOf(edition("HAMVENTION"))
repository.getEdition("HAMVENTION")
// A fresh repository instance (e.g. after a process restart) reads the same DB without re-seeding.
seed.editions = emptyList()
val restarted =
EventFirmwareRepositoryImpl(
remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers),
assetReader = seed,
json = json,
localDataSource = local,
dispatchers = dispatchers,
)
assertEquals("hamvention", restarted.getEdition("HAMVENTION")?.displayName)
}
}

View File

@@ -27,6 +27,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
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.NetworkFirmwareRelease
@@ -50,6 +51,8 @@ class FirmwareReleaseRepositoryImplTest {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused")
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = response
override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
}
/** Serves `firmware_releases.json` from [bundled] via the real decode path, or nothing when null. */

View File

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.dao.EventFirmwareEditionDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -40,6 +41,7 @@ import org.meshtastic.core.database.entity.DeviceLinkEntity
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -70,6 +72,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DiscoverySessionEntity::class,
DiscoveryPresetResultEntity::class,
DiscoveredNodeEntity::class,
EventFirmwareEditionEntity::class,
],
autoMigrations =
[
@@ -116,8 +119,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 43, to = 44),
AutoMigration(from = 44, to = 45),
AutoMigration(from = 45, to = 46),
AutoMigration(from = 46, to = 47),
],
version = 46,
version = 47,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -142,6 +146,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun discoveryDao(): DiscoveryDao
abstract fun eventFirmwareEditionDao(): EventFirmwareEditionDao
companion object {
/**
* Configures a [RoomDatabase.Builder] with standard settings for this project.

View File

@@ -0,0 +1,41 @@
/*
* 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.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
@Dao
interface EventFirmwareEditionDao {
@Upsert suspend fun upsertAll(editions: List<EventFirmwareEditionEntity>)
@Query("SELECT * FROM event_firmware_edition WHERE edition = :edition")
suspend fun getByEdition(edition: String): EventFirmwareEditionEntity?
/**
* Deletes rows whose edition is not in [keep]. WARNING: `NOT IN ()` is always true in SQLite, so an **empty**
* [keep] deletes every row — call sites must guard against passing an empty list (see
* `EventFirmwareEditionLocalDataSource.deleteNotIn`, which no-ops on empty).
*/
@Query("DELETE FROM event_firmware_edition WHERE edition NOT IN (:keep)")
suspend fun deleteNotIn(keep: List<String>)
@Query("SELECT COUNT(*) FROM event_firmware_edition")
suspend fun count(): Int
}

View File

@@ -0,0 +1,91 @@
/*
* 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.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.meshtastic.core.model.EventFirmwareBuild
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.model.EventFirmwareLink
import org.meshtastic.core.model.EventFirmwareTheme
/** Lenient so decoding a cached column survives a model that gained fields since it was written (forward-compat). */
private val entityJson = Json { ignoreUnknownKeys = true }
/**
* An event-firmware display record, cached from the Meshtastic API (`/resource/eventFirmware`) during refresh. The
* nested [links]/[theme]/[firmware] objects are stored pre-serialized rather than via Room type converters — this is
* the only entity that needs those column types, so shared [org.meshtastic.core.database.Converters] entries would be
* unused elsewhere.
*/
@Serializable
@Entity(tableName = "event_firmware_edition")
data class EventFirmwareEditionEntity(
@PrimaryKey val edition: String,
@ColumnInfo(name = "display_name") val displayName: String = "",
@ColumnInfo(name = "welcome_message") val welcomeMessage: String = "",
@ColumnInfo(name = "event_start") val eventStart: String? = null,
@ColumnInfo(name = "event_end") val eventEnd: String? = null,
@ColumnInfo(name = "time_zone") val timeZone: String? = null,
val location: String? = null,
@ColumnInfo(name = "icon_url") val iconUrl: String? = null,
@ColumnInfo(name = "accent_color") val accentColor: String? = null,
val tag: String? = null,
val domain: String? = null,
@ColumnInfo(name = "theme_json") val themeJson: String? = null,
@ColumnInfo(name = "firmware_json") val firmwareJson: String? = null,
@ColumnInfo(name = "links_json") val linksJson: String = "[]",
)
fun EventFirmwareEdition.asEntity() = EventFirmwareEditionEntity(
edition = edition,
displayName = displayName,
welcomeMessage = welcomeMessage,
eventStart = eventStart,
eventEnd = eventEnd,
timeZone = timeZone,
location = location,
iconUrl = iconUrl,
accentColor = accentColor,
tag = tag,
domain = domain,
themeJson = theme?.let { entityJson.encodeToString(it) },
firmwareJson = firmware?.let { entityJson.encodeToString(it) },
linksJson = entityJson.encodeToString(links),
)
fun EventFirmwareEditionEntity.asExternalModel() = EventFirmwareEdition(
edition = edition,
displayName = displayName,
welcomeMessage = welcomeMessage,
eventStart = eventStart,
eventEnd = eventEnd,
timeZone = timeZone,
location = location,
iconUrl = iconUrl,
accentColor = accentColor,
tag = tag,
domain = domain,
theme = themeJson?.let { runCatching { entityJson.decodeFromString<EventFirmwareTheme>(it) }.getOrNull() },
firmware =
firmwareJson?.let { runCatching { entityJson.decodeFromString<EventFirmwareBuild>(it) }.getOrNull() },
links =
runCatching { entityJson.decodeFromString<List<EventFirmwareLink>>(linksJson) }.getOrDefault(emptyList()),
)

View File

@@ -21,9 +21,9 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
/**
* Response envelope for event-firmware display metadata. Matches the bundled `event_firmware.json` and the planned `GET
* /resource/eventFirmware` API, sharing the `{version, generatedAt, source, <payload>[]}` shape used by
* [NetworkDeviceLinksResponse]. See `schemas/event_firmware.schema.json` for the authoring contract.
* Response envelope for event-firmware display metadata. Matches the bundled `event_firmware.json` and the live `GET
* https://api.meshtastic.org/resource/eventFirmware` API (the source of truth), sharing the `{version, generatedAt,
* source, <payload>[]}` shape used by [NetworkDeviceLinksResponse].
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@@ -47,8 +47,12 @@ data class EventFirmwareResponse(
* @param eventEnd ISO-8601 date the event ends, in [timeZone].
* @param timeZone IANA time-zone id so [eventStart]/[eventEnd] resolve in the event's local time.
* @param location free-text venue/city for display.
* @param iconUrl URL of the event branding icon; `null` until icons are hosted (bundled drawables fill in meanwhile).
* @param accentColor `#RRGGBB` brand color for ambient theming (not yet consumed).
* @param iconUrl URL of the event branding icon (hosted on api.meshtastic.org); bundled drawables are the fallback.
* @param accentColor `#RRGGBB` brand color for ambient theming.
* @param tag short label for the event, e.g. `Hamvention`.
* @param domain per-event microsite host, e.g. `hamvention.meshtastic.org`.
* @param theme richer branding (tagline, palette, …) layered over [accentColor]; optional, not all editions carry it.
* @param firmware the event's own firmware build (version + download); lets a client offer/flash it directly.
* @param links labeled links for the event (website, schedule, …).
*/
@Serializable
@@ -64,6 +68,10 @@ data class EventFirmwareEdition(
val location: String? = null,
val iconUrl: String? = null,
val accentColor: String? = null,
val tag: String? = null,
val domain: String? = null,
val theme: EventFirmwareTheme? = null,
val firmware: EventFirmwareBuild? = null,
val links: List<EventFirmwareLink> = emptyList(),
)
@@ -72,3 +80,40 @@ data class EventFirmwareEdition(
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class EventFirmwareLink(val label: String = "", val url: String = "")
/**
* Richer event branding beyond a single accent color. `fonts` is intentionally omitted — it is `null` in the current
* feed and its shape is unspecified, so it is left to [JsonIgnoreUnknownKeys] rather than modeled prematurely.
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class EventFirmwareTheme(
val name: String? = null,
val tagline: String? = null,
val colors: EventFirmwareThemeColors? = null,
val palette: List<String> = emptyList(),
)
/** Named brand colors (`#RRGGBB`) for an event theme; any may be absent. */
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class EventFirmwareThemeColors(
val primary: String? = null,
val secondary: String? = null,
val accent: String? = null,
)
/** The event's own firmware release, so a client can surface its version or offer the download ([zipUrl]). */
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class EventFirmwareBuild(
val slug: String? = null,
val version: String? = null,
val id: String? = null,
val title: String? = null,
val zipUrl: String? = null,
val releaseNotes: String? = null,
)

View File

@@ -0,0 +1,29 @@
/*
* 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.network
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.network.service.ApiService
@Single
class EventFirmwareRemoteDataSource(private val apiService: ApiService, private val dispatchers: CoroutineDispatchers) {
suspend fun getEventFirmware(): EventFirmwareResponse =
withContext(dispatchers.io) { apiService.getEventFirmware() }
}

View File

@@ -20,6 +20,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
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.NetworkFirmwareReleases
@@ -34,6 +35,9 @@ interface ApiService {
/** Fetches the list of available firmware releases from the Meshtastic API. */
suspend fun getFirmwareReleases(): NetworkFirmwareReleases
/** Fetches event-firmware display metadata (editions, welcome messages, links) from the Meshtastic API. */
suspend fun getEventFirmware(): EventFirmwareResponse
}
/**
@@ -51,4 +55,6 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = client.get("resource/deviceLinks").body()
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
override suspend fun getEventFirmware(): EventFirmwareResponse = client.get("resource/eventFirmware").body()
}

View File

@@ -606,6 +606,8 @@
<!-- FIRMWARE -->
<string name="firmware">Firmware</string>
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_event_ended_banner">%1$s has ended. Return to standard Meshtastic firmware to restore normal features.</string>
<string name="firmware_event_ended_button">Update firmware</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
<string name="firmware_recovery_ble_failed">Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>

View File

@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -42,20 +41,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.painterResource
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.ChevronRight
import org.meshtastic.core.ui.icon.LinkIcon
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Place
import org.meshtastic.core.ui.util.EventBrandingIcon
import org.meshtastic.core.ui.util.accentColorOrNull
import org.meshtastic.core.ui.util.eventIconFor
/**
* Bottom sheet shown when the user taps the event branding in [MainAppBar]. Surfaces the event metadata the bundled
@@ -108,14 +105,11 @@ private fun EventHeader(edition: EventFirmwareEdition, accent: Color?) {
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
eventIconFor(edition.edition)?.let { icon ->
Image(
painter = painterResource(icon),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
}
EventBrandingIcon(
edition = edition,
modifier = Modifier.size(48.dp).clip(CircleShape),
contentDescription = null,
)
Text(
text = edition.displayName,
style = MaterialTheme.typography.headlineSmall,

View File

@@ -19,7 +19,6 @@ package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -40,11 +39,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.Node
@@ -53,9 +50,9 @@ import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.EventBrandingIcon
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.accentColorOrNull
import org.meshtastic.core.ui.util.eventIconFor
/** Alpha for the ambient event accent wash over the app bar — subtle enough to keep title text legible. */
private const val EVENT_ACCENT_ALPHA = 0.12f
@@ -134,24 +131,11 @@ private fun EventAwareBranding() {
Icon(imageVector = vectorResource(Res.drawable.ic_meshtastic), contentDescription = null)
return
}
// Every event edition is tappable for its info sheet; editions without a bundled icon reuse the Meshtastic logo.
// Every event edition is tappable for its info sheet. The icon prefers the hosted iconUrl, then a bundled
// drawable, then the Meshtastic logo — see EventBrandingIcon.
var showSheet by remember { mutableStateOf(false) }
val brandingModifier = Modifier.size(32.dp).clip(CircleShape).clickable(role = Role.Button) { showSheet = true }
val iconRes = eventIconFor(eventEdition.edition)
if (iconRes != null) {
Image(
painter = painterResource(iconRes),
contentDescription = eventEdition.displayName,
contentScale = ContentScale.Fit,
modifier = brandingModifier,
)
} else {
Icon(
imageVector = vectorResource(Res.drawable.ic_meshtastic),
contentDescription = eventEdition.displayName,
modifier = brandingModifier,
)
}
EventBrandingIcon(edition = eventEdition, modifier = brandingModifier)
if (showSheet) {
EventInfoSheet(edition = eventEdition, onDismiss = { showSheet = false })
}

View File

@@ -16,12 +16,25 @@
*/
package org.meshtastic.core.ui.util
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.img_event_hamvention
import kotlin.time.Clock
/**
* Provides the active [EventFirmwareEdition] (if any) to the composition tree. When a connected device reports an event
@@ -33,14 +46,61 @@ import org.meshtastic.core.resources.img_event_hamvention
val LocalEventBranding = compositionLocalOf<EventFirmwareEdition?> { null }
/**
* Bundled branding drawable for an edition, or `null`. The metadata's `iconUrl` is null until icons are hosted, so the
* one drawable we ship stays code-mapped here; remove this once icons are loaded from [EventFirmwareEdition.iconUrl].
* Bundled branding drawable for an edition, or `null`. Used as the offline fallback by [EventBrandingIcon] when a
* hosted [EventFirmwareEdition.iconUrl] is absent or fails to load.
*/
fun eventIconFor(editionName: String): DrawableResource? = when (editionName) {
"HAMVENTION" -> Res.drawable.img_event_hamvention
else -> null
}
/**
* Event branding icon: loads the hosted [EventFirmwareEdition.iconUrl] when present, falling back to the bundled
* per-edition drawable ([eventIconFor]), and finally the Meshtastic logo. The fallback painter also backs Coil's
* loading/error states so there is never an empty slot.
*/
@Composable
fun EventBrandingIcon(
edition: EventFirmwareEdition,
modifier: Modifier = Modifier,
contentDescription: String? = edition.displayName,
) {
val bundled = eventIconFor(edition.edition)
val fallback =
bundled?.let { painterResource(it) } ?: rememberVectorPainter(vectorResource(Res.drawable.ic_meshtastic))
val url = edition.iconUrl
if (url.isNullOrBlank()) {
Image(
painter = fallback,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = modifier,
)
} else {
AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
placeholder = fallback,
error = fallback,
fallback = fallback,
)
}
}
/**
* Whether the event's last day is in the past, evaluated in the event's own [timeZone][EventFirmwareEdition.timeZone]
* (falling back to the device time zone). Used to nudge users off event firmware once the event is over. Returns
* `false` when [eventEnd][EventFirmwareEdition.eventEnd] is absent or unparseable — an unknown end date is never
* treated as ended.
*/
fun EventFirmwareEdition.hasEnded(): Boolean {
val end = eventEnd?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return false
val zone = timeZone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: TimeZone.currentSystemDefault()
return Clock.System.todayIn(zone) > end
}
/** Parses the edition's `#RRGGBB` [EventFirmwareEdition.accentColor] into a [Color], or `null` if absent/malformed. */
fun EventFirmwareEdition.accentColorOrNull(): Color? {
val rgb =

View File

@@ -20,12 +20,17 @@ import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.EventFirmwareEdition
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class EventBrandingTest {
private fun accent(hex: String?) = EventFirmwareEdition(edition = "X", accentColor = hex).accentColorOrNull()
private fun ended(end: String?, tz: String? = null) =
EventFirmwareEdition(edition = "X", eventEnd = end, timeZone = tz).hasEnded()
@Test
fun parsesRrggbbWithHash() {
assertEquals(Color(red = 0x00, green = 0x5D, blue = 0xAA), accent("#005DAA"))
@@ -42,4 +47,27 @@ class EventBrandingTest {
assertNull(accent("#12345")) // too short
assertNull(accent("#GGGGGG")) // not hex
}
@Test
fun hasEndedTrueForPastDate() {
assertTrue(ended("2000-01-01"))
assertTrue(ended("2000-01-01", tz = "America/New_York"))
}
@Test
fun hasEndedFalseForFutureDate() {
assertFalse(ended("9999-01-01"))
}
@Test
fun hasEndedFalseWhenEndDateMissingOrUnparseable() {
assertFalse(ended(null))
assertFalse(ended("not-a-date"))
}
@Test
fun hasEndedFallsBackToSystemZoneWhenTimeZoneUnparseable() {
// Bad IANA id must not throw — it falls back to the device zone, and a long-past date is still ended.
assertTrue(ended("2000-01-01", tz = "Not/AZone"))
}
}

View File

@@ -60,6 +60,8 @@ import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_disabled
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.firmware_event_ended_banner
import org.meshtastic.core.resources.firmware_event_ended_button
import org.meshtastic.core.resources.firmware_recovery_banner
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_dismiss
@@ -77,7 +79,9 @@ import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.hasEnded
import org.meshtastic.core.ui.util.isBluetoothDisabled
import org.meshtastic.core.ui.util.isWifiUnavailable
import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
@@ -319,6 +323,23 @@ fun ConnectionsScreen(
)
}
// Once an event is over, nudge users still on that event's firmware back to standard
// firmware. Driven purely by the metadata end date (LocalEventBranding is only populated
// while connected to event firmware), so it appears whenever an ended-event device is
// connected and disappears on its own once the device is re-flashed to vanilla. Not
// dismissable — it stays until the underlying condition is actually resolved.
LocalEventBranding.current
?.takeIf { it.hasEnded() }
?.let { endedEvent ->
Spacer(modifier = Modifier.height(8.dp))
RecoveryCard(
message =
stringResource(Res.string.firmware_event_ended_banner, endedEvent.displayName),
actionLabel = stringResource(Res.string.firmware_event_ended_button),
onAction = { onConfigNavigate(FirmwareRoute.FirmwareUpdate) },
)
}
// Region warning sits outside the animated card so it does not affect the
// CONNECTED ↔ CONNECTING ↔ NO_DEVICE size transition.
val isPhysicalDevice =

View File

@@ -1,94 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://meshtastic.org/schemas/event_firmware.schema.json",
"title": "Event Firmware Metadata",
"description": "Offline-first display metadata for event-specific firmware editions. The bundled androidApp/src/main/assets/event_firmware.json shares the {version, generatedAt, source, <payload>[]} envelope used by device_links.json so a future Meshtastic API (planned GET /resource/eventFirmware) can reuse the same shape. Each record identifies its edition by the FirmwareEdition proto enum name in the `edition` field. This schema is the authoring contract for the bundled file and is bumped as the shape evolves; the runtime JSON parser is independently tolerant of unknown fields.",
"type": "object",
"required": ["version", "editions"],
"additionalProperties": false,
"properties": {
"version": {
"type": "integer",
"minimum": 1,
"description": "Schema/envelope version. Bump when the structure changes incompatibly."
},
"generatedAt": {
"type": "string",
"format": "date-time",
"description": "ISO-8601 timestamp the snapshot was generated."
},
"source": {
"type": "string",
"description": "Origin of this snapshot, e.g. \"bundled\" or the API URL it was frozen from."
},
"editions": {
"type": "array",
"description": "One record per event firmware edition.",
"items": { "$ref": "#/$defs/edition" }
}
},
"$defs": {
"edition": {
"type": "object",
"required": ["edition", "displayName", "welcomeMessage"],
"additionalProperties": false,
"properties": {
"edition": {
"type": "string",
"pattern": "^[A-Z0-9_]+$",
"description": "FirmwareEdition proto enum name, e.g. \"HAMVENTION\". A pattern (not a fixed enum list) so adding a new event needs no schema change."
},
"displayName": {
"type": "string",
"description": "Human-readable name shown in the UI, e.g. \"Hamvention\" (vs the enum name \"HAMVENTION\")."
},
"welcomeMessage": {
"type": "string",
"description": "Plain (English) welcome string. NOTE: this is intentionally NOT localized — moving it into this data file drops the Crowdin per-language translation the app's string resources previously provided. Revisit when an upstream API / localization strategy is added."
},
"eventStart": {
"type": ["string", "null"],
"format": "date",
"description": "ISO-8601 date (YYYY-MM-DD) the event begins, interpreted in timeZone. Seed values are best-effort and change yearly; the upstream sync is expected to own them."
},
"eventEnd": {
"type": ["string", "null"],
"format": "date",
"description": "ISO-8601 date (YYYY-MM-DD) the event ends, interpreted in timeZone."
},
"timeZone": {
"type": ["string", "null"],
"description": "IANA time zone id (e.g. \"America/New_York\") so eventStart/eventEnd resolve in the event's local time, not the device's."
},
"location": {
"type": ["string", "null"],
"description": "Free-text venue/city for display, e.g. \"Las Vegas, Nevada, USA\"."
},
"iconUrl": {
"type": ["string", "null"],
"format": "uri",
"description": "URL of the event branding icon. Null when no hosted icon exists yet."
},
"accentColor": {
"type": ["string", "null"],
"pattern": "^#[0-9A-Fa-f]{6}$",
"description": "Brand/accent color (#RRGGBB) for ambient event theming."
},
"links": {
"type": "array",
"description": "Labeled links for the event (website, schedule, map, …).",
"items": { "$ref": "#/$defs/link" }
}
}
},
"link": {
"type": "object",
"required": ["label", "url"],
"additionalProperties": false,
"properties": {
"label": { "type": "string", "description": "Display text for the link." },
"url": { "type": "string", "format": "uri", "description": "Destination URL." }
}
}
}
}