mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-10 05:15:46 -04:00
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:
58
.github/workflows/scheduled-updates.yml
vendored
58
.github/workflows/scheduled-updates.yml
vendored
@@ -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
|
||||
|
||||
2
.skills/compose-ui/strings-index.txt
generated
2
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user