From 9e55cdb3eb4e8b8cd4369134052640a7d6191e8b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:53:32 -0500 Subject: [PATCH] feat: wire up event-firmware metadata API (offline-first) + post-event firmware reminder (#6162) Co-authored-by: Claude Opus 4.8 --- .github/workflows/scheduled-updates.yml | 58 +- .skills/compose-ui/strings-index.txt | 2 + .../meshtastic/app/di/FDroidNetworkModule.kt | 4 + .../src/main/assets/event_firmware.json | 162 +- .../EventFirmwareEditionLocalDataSource.kt | 47 + .../repository/EventFirmwareRepositoryImpl.kt | 111 +- .../DeviceLinkRepositoryImplTest.kt | 3 + .../EventFirmwareRepositoryImplTest.kt | 172 +- .../FirmwareReleaseRepositoryImplTest.kt | 3 + .../47.json | 1693 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 8 +- .../database/dao/EventFirmwareEditionDao.kt | 41 + .../entity/EventFirmwareEditionEntity.kt | 91 + .../meshtastic/core/model/EventFirmware.kt | 55 +- .../network/EventFirmwareRemoteDataSource.kt | 29 + .../core/network/service/ApiService.kt | 6 + .../composeResources/values/strings.xml | 2 + .../core/ui/component/EventInfoSheet.kt | 18 +- .../core/ui/component/MainAppBar.kt | 24 +- .../core/ui/util/LocalEventBranding.kt | 64 +- .../core/ui/util/EventBrandingTest.kt | 28 + .../connections/ui/ConnectionsScreen.kt | 21 + schemas/event_firmware.schema.json | 94 - 23 files changed, 2537 insertions(+), 199 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt delete mode 100644 schemas/event_firmware.schema.json diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index 9ada55343..053a23fa5 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -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 diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 163409208..ce8bd78bf 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt index 4cdaabc0b..e45e0433a 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt @@ -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.") } } diff --git a/androidApp/src/main/assets/event_firmware.json b/androidApp/src/main/assets/event_firmware.json index 2ce7fee2e..362fab796 100644 --- a/androidApp/src/main/assets/event_firmware.json +++ b/androidApp/src/main/assets/event_firmware.json @@ -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 + } } ] } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt new file mode 100644 index 000000000..dfc214ead --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt @@ -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 . + */ +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) = + 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) { + if (keep.isEmpty()) return + withContext(dispatchers.io) { dao.deleteNotIn(keep) } + } + + suspend fun count(): Int = withContext(dispatchers.io) { dao.count() } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt index c1e1aa31c..084ba6db6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt @@ -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? = 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? = 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(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 { - cache?.let { - return it - } - return mutex.withLock { - cache - ?: withContext(dispatchers.io) { - safeCatching { assetReader.decode(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 } } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt index e8bdcced7..65957136e 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt @@ -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. */ diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt index 51a7e2746..5fb8cc0f6 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt @@ -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 = 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, 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) } } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt index 92baf9651..5b02c301b 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt @@ -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. */ diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json new file mode 100644 index 000000000..b3dd35df9 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json @@ -0,0 +1,1693 @@ +{ + "formatVersion": 1, + "database": { + "version": 47, + "identityHash": "902c451b0845b51265520d6e7a7c6b25", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "powerChannelLabels", + "columnName": "power_channel_labels", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "signsPackets", + "columnName": "has_xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translatedText", + "columnName": "translated_text", + "affinity": "TEXT" + }, + { + "fieldPath": "showTranslated", + "columnName": "show_translated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + }, + { + "fieldPath": "targets", + "columnName": "targets", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "infrastructureNodeCount", + "columnName": "infrastructure_node_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isInfrastructure", + "columnName": "is_infrastructure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_firmware_edition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))", + "fields": [ + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "welcomeMessage", + "columnName": "welcome_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventStart", + "columnName": "event_start", + "affinity": "TEXT" + }, + { + "fieldPath": "eventEnd", + "columnName": "event_end", + "affinity": "TEXT" + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "accentColor", + "columnName": "accent_color", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "themeJson", + "columnName": "theme_json", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareJson", + "columnName": "firmware_json", + "affinity": "TEXT" + }, + { + "fieldPath": "linksJson", + "columnName": "links_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "edition" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '902c451b0845b51265520d6e7a7c6b25')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 2ac09e71c..0ae839a38 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt new file mode 100644 index 000000000..901df227d --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt @@ -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 . + */ +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) + + @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) + + @Query("SELECT COUNT(*) FROM event_firmware_edition") + suspend fun count(): Int +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt new file mode 100644 index 000000000..1f55e993f --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt @@ -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 . + */ +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(it) }.getOrNull() }, + firmware = + firmwareJson?.let { runCatching { entityJson.decodeFromString(it) }.getOrNull() }, + links = + runCatching { entityJson.decodeFromString>(linksJson) }.getOrDefault(emptyList()), +) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt index 195fc831b..613c48fa1 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt @@ -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, []}` 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, []}` 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 = 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 = 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, +) diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt new file mode 100644 index 000000000..843e030d7 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt @@ -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 . + */ +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() } +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt index 94c7697a9..b0fd88d3f 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -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() } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9c736c4bd..95ef6cc5b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -606,6 +606,8 @@ Firmware Firmware Edition + %1$s has ended. Return to standard Meshtastic firmware to restore normal features. + Update firmware The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide. Finish updating %1$s 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. diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt index 4189ad2f1..77dfd0903 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt @@ -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, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt index 8d75d0970..0b1fb79cc 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt @@ -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 }) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt index d3548e7e3..246fc1619 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt @@ -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 { 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 = diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt index 2f71918ee..d610bc497 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt @@ -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")) + } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 50bc0837d..0a535530a 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -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 = diff --git a/schemas/event_firmware.schema.json b/schemas/event_firmware.schema.json deleted file mode 100644 index a071380da..000000000 --- a/schemas/event_firmware.schema.json +++ /dev/null @@ -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, []} 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." } - } - } - } -}