From 91d2f7660bc7d554e9753dc2814864c7b4239a45 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:31:25 -0700 Subject: [PATCH] Add firmware update notice (#6309) Co-authored-by: Benjamin Faershtein Co-authored-by: James Rich Co-authored-by: Claude Opus 4.8 --- .../FirmwareReleaseRepositoryImpl.kt | 19 ++ .../DeviceLinkRepositoryImplTest.kt | 3 + .../EventFirmwareRepositoryImplTest.kt | 3 + .../FirmwareReleaseRepositoryImplTest.kt | 24 +++ .../core/model/FirmwareReleaseManifest.kt | 26 +++ .../core/model/FirmwareUpdateNoticePolicy.kt | 127 ++++++++++++ .../model/FirmwareUpdateNoticePolicyTest.kt | 113 +++++++++++ .../FirmwareReleaseRemoteDataSource.kt | 4 + .../core/network/service/ApiService.kt | 7 + .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 26 +++ .../core/prefs/ui/UiPrefsImplTest.kt | 12 ++ .../core/repository/AppPreferences.kt | 6 + .../repository/FirmwareReleaseRepository.kt | 6 + .../core/repository/NotificationManager.kt | 3 +- .../composeResources/values/strings.xml | 6 +- .../service/AndroidNotificationManagerTest.kt | 19 +- .../service/AndroidNotificationManager.kt | 22 ++- .../core/testing/FakeAppPreferences.kt | 6 + .../testing/FakeFirmwareReleaseRepository.kt | 12 +- .../core/ui/viewmodel/ConnectionsViewModel.kt | 182 ++++++++++++++++++ .../ui/viewmodel/ConnectionsViewModelTest.kt | 146 +++++++++++++- .../desktop/DesktopNotificationManager.kt | 5 +- .../connections/ui/ConnectionsScreen.kt | 81 ++++++++ 23 files changed, 838 insertions(+), 20 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index 0c43ce8052..598e390135 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -55,6 +55,10 @@ open class FirmwareReleaseRepositoryImpl( /** Single-flight guard so concurrent collectors share one network refresh. */ private val refreshMutex = Mutex() + /** Serializes target-manifest downloads and preserves successful results for the selected release URL. */ + private val manifestMutex = Mutex() + private val manifestTargetsByUrl = mutableMapOf>() + /** * Guards [bundledSnapshot] decode so concurrent collectors decode the bundled JSON at most once per process. The * apply/skip decision itself is re-evaluated every time against the CURRENT active DB — the active Room database @@ -75,6 +79,21 @@ open class FirmwareReleaseRepositoryImpl( override val nightlyRelease: Flow = getLatestFirmware(FirmwareReleaseType.NIGHTLY) + override suspend fun getManifestTargets(release: FirmwareRelease): Set? { + val manifestUrl = release.zipUrl.takeIf { it.isNotBlank() } ?: return null + return manifestMutex.withLock { + manifestTargetsByUrl[manifestUrl] + ?: safeCatching { remoteDataSource.getFirmwareReleaseManifest(manifestUrl) } + .onFailure { error -> Logger.w(error) { "FirmwareReleaseRepository: manifest fetch failed" } } + .getOrNull() + ?.targets + ?.map { target -> target.board.trim() } + ?.filter(String::isNotBlank) + ?.toSet() + ?.also { targets -> manifestTargetsByUrl[manifestUrl] = targets } + } + } + private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow = staleWhileRevalidateFlow( loadFromCache = { ensureSeeded() 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 04de52a0ac..673092a329 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 @@ -25,6 +25,7 @@ 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.FirmwareReleaseManifest import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLink import org.meshtastic.core.model.NetworkDeviceLinksResponse @@ -49,6 +50,8 @@ class DeviceLinkRepositoryImplTest { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") + override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") 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 1f87142446..bdde1a959f 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 @@ -30,6 +30,7 @@ import org.meshtastic.core.model.EventFirmwareFonts import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.EventFirmwareTheme import org.meshtastic.core.model.EventFirmwareThemeColors +import org.meshtastic.core.model.FirmwareReleaseManifest import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse import org.meshtastic.core.model.NetworkFirmwareNightly @@ -56,6 +57,8 @@ class EventFirmwareRepositoryImplTest { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") + override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") override suspend fun getEventFirmware(): EventFirmwareResponse { 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 bd71acc5c8..458a073271 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 @@ -28,6 +28,8 @@ 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.FirmwareReleaseManifest +import org.meshtastic.core.model.FirmwareTarget import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse import org.meshtastic.core.model.NetworkFirmwareNightly @@ -49,6 +51,8 @@ class FirmwareReleaseRepositoryImplTest { private class FakeApiService(var response: NetworkFirmwareReleases) : ApiService { var nightly: NetworkFirmwareNightly? = null var nightlyUnreachable = false + var manifest: FirmwareReleaseManifest? = null + var manifestCalls = 0 override suspend fun getDeviceHardware(): List = error("unused") @@ -56,6 +60,11 @@ class FirmwareReleaseRepositoryImplTest { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = response + override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest { + manifestCalls++ + return checkNotNull(manifest) { "manifest not configured" } + } + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { if (nightlyUnreachable) error("nightly index unreachable") return nightly @@ -139,6 +148,21 @@ class FirmwareReleaseRepositoryImplTest { ) } + @Test + fun `manifest board targets are fetched once and cached by release URL`() = runBlocking { + val release = + org.meshtastic.core.database.entity.FirmwareRelease(id = "v2.8.0", zipUrl = "https://example.com/manifest") + api.manifest = + FirmwareReleaseManifest( + version = "2.8.0", + targets = listOf(FirmwareTarget(board = "tbeam-s3-core", platform = "esp32s3")), + ) + + assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release)) + assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release)) + assertEquals(1, api.manifestCalls) + } + @Test fun refreshLeavesLocalRowsUntouched() = runBlocking { dao.insert(staleRow("v2.7.15.567b8ea", FirmwareReleaseType.STABLE)) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt new file mode 100644 index 0000000000..5dc0cfab02 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt @@ -0,0 +1,26 @@ +/* + * 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.model + +import kotlinx.serialization.Serializable + +/** Authoritative target catalogue referenced by a firmware release's `zip_url`. */ +@Serializable +data class FirmwareReleaseManifest(val version: String = "", val targets: List = emptyList()) + +/** One firmware target declared by a [FirmwareReleaseManifest]. */ +@Serializable data class FirmwareTarget(val board: String = "", val platform: String = "") diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt new file mode 100644 index 0000000000..fec33793e7 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt @@ -0,0 +1,127 @@ +/* + * 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.model + +/** Connection transports that can determine the Android firmware-update destination. */ +enum class FirmwareUpdateTransport { + Bluetooth, + Serial, + Tcp, +} + +/** Where a firmware-update nudge takes the user. */ +enum class FirmwareUpdateDestination { + AndroidUpdate, + MeshtasticFlasher, +} + +/** The only permitted visual treatment for a firmware update: an informational update nudge. */ +enum class FirmwareUpdateNoticePresentation { + Update, +} + +/** Fully validated connected-device firmware update information for platform UI and notifications. */ +data class FirmwareUpdateNotice( + val notificationKey: String, + val currentVersion: String, + val stableVersion: String, + val destination: FirmwareUpdateDestination, + val presentation: FirmwareUpdateNoticePresentation = FirmwareUpdateNoticePresentation.Update, +) + +/** + * Determines whether a connected local device is behind the latest stable firmware and where it can be updated. + * + * The policy deliberately fails closed: a notice needs a known node identity, hardware target, and valid current and + * stable versions. It must never interpret an unknown or malformed version as an update opportunity. + */ +object FirmwareUpdateNoticePolicy { + private val VERSION_REGEX = Regex("^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:[.+-].*)?$") + + @Suppress("ReturnCount") + fun createNotice( + nodeIdentity: String?, + currentVersion: String?, + stableVersion: String?, + hardware: DeviceHardware?, + transport: FirmwareUpdateTransport, + releaseTargets: Set, + ): FirmwareUpdateNotice? { + val identity = nodeIdentity?.trim().takeIf { !it.isNullOrEmpty() } ?: return null + val deviceHardware = hardware ?: return null + val target = deviceHardware.platformioTarget.trim().takeIf { it.isNotEmpty() } ?: return null + if (releaseTargets.none { it.equals(target, ignoreCase = true) }) return null + val current = currentVersion?.let(::parseVersion) ?: return null + val stable = stableVersion?.let(::parseVersion) ?: return null + if (current >= stable) return null + + return FirmwareUpdateNotice( + notificationKey = notificationKey(identity, target, stable.normalized), + currentVersion = current.normalized, + stableVersion = stable.normalized, + destination = destinationFor(deviceHardware, transport), + ) + } + + fun notificationKey(nodeIdentity: String, hardwareTarget: String, stableVersion: String): String { + val normalizedStable = parseVersion(stableVersion)?.normalized ?: stableVersion.trim() + return "firmware-update-notified:$nodeIdentity:$hardwareTarget:$normalizedStable" + } + + fun shouldSchedule(notificationKey: String, alreadyScheduled: Set): Boolean = + notificationKey !in alreadyScheduled + + private fun destinationFor( + hardware: DeviceHardware, + transport: FirmwareUpdateTransport, + ): FirmwareUpdateDestination = if (hardware.supportsAndroidUpdate(transport)) { + FirmwareUpdateDestination.AndroidUpdate + } else { + FirmwareUpdateDestination.MeshtasticFlasher + } + + private fun DeviceHardware.supportsAndroidUpdate(transport: FirmwareUpdateTransport): Boolean = when (transport) { + FirmwareUpdateTransport.Bluetooth -> isEsp32Arc || architecture.contains("nrf", ignoreCase = true) + + FirmwareUpdateTransport.Serial -> + !isEsp32Arc && + ( + architecture.contains("nrf", ignoreCase = true) || + architecture.contains("rp2040", ignoreCase = true) + ) + + FirmwareUpdateTransport.Tcp -> isEsp32Arc + } + + @Suppress("ReturnCount") + private fun parseVersion(value: String): ParsedVersion? { + val match = VERSION_REGEX.matchEntire(value.trim()) ?: return null + val (major, minor, patch) = match.destructured + return ParsedVersion( + major = major.toIntOrNull() ?: return null, + minor = minor.toIntOrNull() ?: return null, + patch = patch.toIntOrNull() ?: return null, + ) + } + + private data class ParsedVersion(val major: Int, val minor: Int, val patch: Int) : Comparable { + val normalized: String = "$major.$minor.$patch" + + override fun compareTo(other: ParsedVersion): Int = + compareValuesBy(this, other, ParsedVersion::major, ParsedVersion::minor, ParsedVersion::patch) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt new file mode 100644 index 0000000000..ea415095e0 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt @@ -0,0 +1,113 @@ +/* + * 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.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FirmwareUpdateNoticePolicyTest { + + @Test + fun `creates an informational OTA update notice for an older connected ESP32 node`() { + val notice = + FirmwareUpdateNoticePolicy.createNotice( + nodeIdentity = "!12345678", + currentVersion = "v2.7.15", + stableVersion = "v2.7.16", + hardware = DeviceHardware(architecture = "esp32-s3", platformioTarget = "t-echo"), + transport = FirmwareUpdateTransport.Bluetooth, + releaseTargets = setOf("t-echo"), + ) + + requireNotNull(notice) + assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination) + assertEquals(FirmwareUpdateNoticePresentation.Update, notice.presentation) + assertEquals("2.7.15", notice.currentVersion) + assertEquals("2.7.16", notice.stableVersion) + } + + @Test + fun `does not create a notice for missing malformed current equal or newer versions`() { + val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "t-beam") + + listOf(null, "", "unknown", "2.7.16", "2.8.0").forEach { currentVersion -> + assertNull( + FirmwareUpdateNoticePolicy.createNotice( + nodeIdentity = "!12345678", + currentVersion = currentVersion, + stableVersion = "v2.7.16", + hardware = hardware, + transport = FirmwareUpdateTransport.Bluetooth, + releaseTargets = setOf("t-beam"), + ), + ) + } + } + + @Test + fun `selects Meshtastic Flasher when Android cannot update the hardware over the active connection`() { + val notice = + FirmwareUpdateNoticePolicy.createNotice( + nodeIdentity = "!12345678", + currentVersion = "2.7.15", + stableVersion = "2.7.16", + hardware = DeviceHardware(architecture = "nrf52840", platformioTarget = "rak4631"), + transport = FirmwareUpdateTransport.Tcp, + releaseTargets = setOf("rak4631"), + ) + + assertEquals(FirmwareUpdateDestination.MeshtasticFlasher, notice?.destination) + } + + @Test + fun `dedupe key scopes notifications to node hardware target and stable version`() { + val first = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "v2.7.16") + val sameStableVersion = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.16") + val newerStableVersion = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.17") + val differentTarget = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-echo", "2.7.16") + + assertEquals(first, sameStableVersion) + assertFalse(first == newerStableVersion) + assertFalse(first == differentTarget) + } + + @Test + fun `does not create a notice when the stable manifest omits the hardware target`() { + val notice = + FirmwareUpdateNoticePolicy.createNotice( + nodeIdentity = "!12345678", + currentVersion = "2.7.15", + stableVersion = "2.7.16", + hardware = DeviceHardware(architecture = "esp32", platformioTarget = "t-beam"), + transport = FirmwareUpdateTransport.Bluetooth, + releaseTargets = setOf("t-echo"), + ) + + assertNull(notice) + } + + @Test + fun `dedupe only suppresses a previously scheduled notification`() { + val key = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.16") + + assertTrue(FirmwareUpdateNoticePolicy.shouldSchedule(key, alreadyScheduled = emptySet())) + assertFalse(FirmwareUpdateNoticePolicy.shouldSchedule(key, alreadyScheduled = setOf(key))) + } +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt index b0254e2869..87f8884f8f 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.network import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.FirmwareReleaseManifest import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.service.ApiService @@ -31,6 +32,9 @@ class FirmwareReleaseRemoteDataSource( suspend fun getFirmwareReleases(): NetworkFirmwareReleases = withContext(dispatchers.io) { apiService.getFirmwareReleases() } + suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = + withContext(dispatchers.io) { apiService.getFirmwareReleaseManifest(manifestUrl) } + /** The nightly preview pointer from meshtastic.github.io, or null when no nightly is published. */ suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = withContext(dispatchers.io) { apiService.getNightlyFirmware() } 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 8662906b79..8f45510582 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 @@ -25,6 +25,7 @@ import io.ktor.http.isSuccess import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.model.EventFirmwareResponse +import org.meshtastic.core.model.FirmwareReleaseManifest import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse import org.meshtastic.core.model.NetworkFirmwareNightly @@ -50,6 +51,9 @@ interface ApiService { /** Fetches the list of available firmware releases from the Meshtastic API. */ suspend fun getFirmwareReleases(): NetworkFirmwareReleases + /** Fetches the target manifest referenced by a firmware release's `zip_url`. */ + suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest + /** * Fetches the nightly preview build pointer from meshtastic.github.io. Returns null when no nightly is currently * published (HTTP 404); throws on transport or server errors so callers can distinguish "gone" from "unreachable". @@ -76,6 +80,9 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService { override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body() + override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = + client.get(manifestUrl).body() + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { val response = client.get(NIGHTLY_INDEX_URL) return when { diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 4b0acb5113..5d111aba84 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -168,6 +168,30 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_SELECTED_CONNECTION_TRANSPORT] = type.name } } } + override val firmwareUpdateNotificationKeys: StateFlow> = + dataStore.data + .map { preferences -> + preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS]?.split('|')?.filter(String::isNotBlank)?.toSet() + ?: emptySet() + } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun recordFirmwareUpdateNotificationKey(key: String) { + scope.launch { + dataStore.edit { preferences -> + val keys = + preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS] + ?.split('|') + ?.filter(String::isNotBlank) + ?.toMutableList() ?: mutableListOf() + keys.remove(key) + keys.add(key) + preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS] = + keys.takeLast(MAX_FIRMWARE_UPDATE_NOTIFICATION_KEYS).joinToString("|") + } + } + } + override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = cachedFlow(provideNodeLocationFlows, nodeNum) { val key = booleanPreferencesKey(provideLocationKey(nodeNum)) @@ -290,9 +314,11 @@ class UiPrefsImpl( val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan") val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan") val KEY_SELECTED_CONNECTION_TRANSPORT = stringPreferencesKey("selected-connection-transport") + val KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS = stringPreferencesKey("firmware-update-notification-keys") val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport") val KEY_SHOW_NETWORK_TRANSPORT = booleanPreferencesKey("show-network-transport") val KEY_SHOW_USB_TRANSPORT = booleanPreferencesKey("show-usb-transport") + private const val MAX_FIRMWARE_UPDATE_NOTIFICATION_KEYS = 100 private fun parseDeviceType(name: String): DeviceType? = DeviceType.entries.firstOrNull { it.name == name } diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt index 789756714d..9c2615dade 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt @@ -124,4 +124,16 @@ class UiPrefsImplTest { assertEquals(DeviceType.USB, prefs.selectedConnectionTransport.value) } + + @Test + fun `firmware update notification keys persist without duplicates`() = testScope.runTest { + prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.8.0") + prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.8.0") + prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.9.0") + + assertEquals( + setOf("firmware-update-notified:node:target:2.8.0", "firmware-update-notified:node:target:2.9.0"), + prefs.firmwareUpdateNotificationKeys.value, + ) + } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index a84cac27d3..371ae7e65a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -146,6 +146,12 @@ interface UiPrefs { fun setSelectedConnectionTransport(type: DeviceType) + /** Keys for firmware-update notifications already scheduled on this device. */ + val firmwareUpdateNotificationKeys: StateFlow> + + /** Records a notification key after the platform notification has been scheduled successfully. */ + fun recordFirmwareUpdateNotificationKey(key: String) + fun shouldProvideNodeLocation(nodeNum: Int): StateFlow fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt index 93baf47ea4..dda6df520d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt @@ -32,6 +32,12 @@ interface FirmwareReleaseRepository { */ val nightlyRelease: Flow + /** + * Fetches the authoritative firmware board targets from a release's manifest URL. Returns null when the manifest + * cannot be retrieved or parsed, so callers can fail closed rather than offering an incompatible firmware update. + */ + suspend fun getManifestTargets(release: FirmwareRelease): Set? + /** Invalidates the local cache of firmware releases. */ suspend fun invalidateCache() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt index 5805d57c6c..deed94c8b4 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt @@ -24,7 +24,8 @@ package org.meshtastic.core.repository * [MeshNotificationManager], which composes over this dispatcher. */ interface NotificationManager { - fun dispatch(notification: Notification) + /** Returns true only when the platform accepted the notification for delivery. */ + fun dispatch(notification: Notification): Boolean fun cancel(id: Int) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1a7f3f851e..49fd280ca8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -630,6 +630,7 @@ Almost there... Alpha No firmware for %2$s (%3$s) was found in \"%1$s\". + Firmware update available Battery too low (%1$d%). Please charge your device before updating. Checking for updates... Start Flashing @@ -669,6 +670,10 @@ No device connected Node user information is missing. Could not find firmware for %1$s in release. + %1$s is installed. Stable %2$s is available. Open Firmware Updates when you're ready. + %1$s is installed. Stable %2$s is available. Open Meshtastic Flasher to update this hardware. + Open Firmware Updates + Open Meshtastic Flasher OTA update failed: %1$s This device\'s OTA loader rejected the requested update method: %1$s For RAK WisBlock RAK4631, use the vendor\'s serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader. @@ -1829,4 +1834,3 @@ 简体中文 繁體中文 - diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt index 09c0e47a7d..db9afbdf2f 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt @@ -29,6 +29,7 @@ import org.meshtastic.core.repository.Notification import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -85,6 +86,18 @@ class AndroidNotificationManagerTest { assertEquals(NotificationChannels.NEW_NODES, posted.channelId) } + @Test + fun `dispatch reports false when its notification channel is disabled`() { + createChannel(NotificationChannels.NEW_NODES, NotificationManager.IMPORTANCE_NONE) + val manager = AndroidNotificationManager(context) + + val dispatched = + manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent)) + + assertFalse(dispatched) + assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size) + } + @Test fun `removeLegacyCategoryChannels removes all known legacy category channels`() { NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel) @@ -187,10 +200,8 @@ class AndroidNotificationManagerTest { assertEquals(expectedChannelId, posted.channelId) } - private fun createChannel(id: String) { - systemNotificationManager.createNotificationChannel( - NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT), - ) + private fun createChannel(id: String, importance: Int = NotificationManager.IMPORTANCE_DEFAULT) { + systemNotificationManager.createNotificationChannel(NotificationChannel(id, id, importance)) } /** diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt index 0e102d374f..980fa6d3dc 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import org.koin.core.annotation.Single @@ -118,11 +119,13 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan ChannelConfig(id = NotificationChannels.SERVICE, importance = SystemNotificationManager.IMPORTANCE_MIN) } - override fun dispatch(notification: Notification) { + override fun dispatch(notification: Notification): Boolean { ensureChannelsInitialized() + val channelId = notification.category.channelConfig().id + if (!canPostNotifications(channelId)) return false val id = notification.id ?: notification.hashCode() val builder = - NotificationCompat.Builder(context, notification.category.channelConfig().id) + NotificationCompat.Builder(context, channelId) .setContentTitle(notification.title) .setContentText(notification.message) .setSmallIcon(drawable.meshtastic_ic_notification) @@ -137,9 +140,22 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan notification.deepLinkUri?.let { uri -> builder.setContentIntent(createDeepLinkPendingIntent(uri, id)) } - notificationManager.notify(id, builder.build()) + return try { + notificationManager.notify(id, builder.build()) + true + } catch (_: SecurityException) { + false + } } + private fun canPostNotifications(channelId: String): Boolean = + NotificationManagerCompat.from(context).areNotificationsEnabled() && + ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + notificationManager.getNotificationChannel(channelId)?.importance != + SystemNotificationManager.IMPORTANCE_NONE + ) + /** * Builds a [PendingIntent] that launches [MainActivity] with the given deep-link URI as [Intent.ACTION_VIEW], so * the existing deep-link plumbing (`UIViewModel.handleDeepLink` → `DeepLinkRouter` → `MultiBackstack`) can diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index f8695f97de..088859ff15 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -175,6 +175,12 @@ class FakeUiPrefs : UiPrefs { selectedConnectionTransport.value = type } + override val firmwareUpdateNotificationKeys = MutableStateFlow>(emptySet()) + + override fun recordFirmwareUpdateNotificationKey(key: String) { + firmwareUpdateNotificationKeys.value += key + } + private val nodeLocationEnabled = mutableMapOf>() override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt index 534f6fd00a..2b87584851 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -33,16 +33,22 @@ class FakeFirmwareReleaseRepository : private val _stableRelease = mutableStateFlow(null) private val _alphaRelease = mutableStateFlow(null) private val _nightlyRelease = mutableStateFlow(null) + private val manifestTargets = mutableMapOf?>() override val stableRelease: Flow = _stableRelease override val alphaRelease: Flow = _alphaRelease override val nightlyRelease: Flow = _nightlyRelease + override suspend fun getManifestTargets(release: FirmwareRelease): Set? = manifestTargets[release.id] + var invalidateCacheCalls: Int = 0 private set init { - registerResetAction { invalidateCacheCalls = 0 } + registerResetAction { + invalidateCacheCalls = 0 + manifestTargets.clear() + } } override suspend fun invalidateCache() { @@ -60,4 +66,8 @@ class FakeFirmwareReleaseRepository : fun setNightlyRelease(release: FirmwareRelease?) { _nightlyRelease.value = release } + + fun setManifestTargets(releaseId: String, targets: Set?) { + manifestTargets[releaseId] = targets + } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index 61a1b0e27f..e52dfbc995 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -17,20 +17,45 @@ package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.FirmwareUpdateNotice +import org.meshtastic.core.model.FirmwareUpdateNoticePolicy +import org.meshtastic.core.model.FirmwareUpdateTransport import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.firmware_update_available +import org.meshtastic.core.resources.firmware_update_notification_android +import org.meshtastic.core.resources.firmware_update_notification_flasher +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig @@ -68,8 +93,14 @@ class ConnectionsViewModel( serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val uiPrefs: UiPrefs, + private val deviceHardwareRepository: DeviceHardwareRepository, + private val firmwareReleaseRepository: FirmwareReleaseRepository, + private val radioPrefs: RadioPrefs, + private val notificationManager: NotificationManager, ) : ViewModel() { + private val scheduledFirmwareUpdateNotificationKeys = mutableSetOf() + val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) @@ -141,4 +172,155 @@ class ConnectionsViewModel( _hasShownNotPairedWarning.value = true uiPrefs.setHasShownNotPairedWarning(true) } + + private val localHardware = + combine(nodeRepository.myNodeInfo, nodeRepository.ourNodeInfo) { myNode, ourNode -> + val hardwareModel = ourNode?.user?.hw_model?.value ?: return@combine null + val target = myNode?.pioEnv?.takeIf { it.isNotBlank() } + hardwareModel to target + } + .flatMapLatest { query -> + query?.let { (hardwareModel, target) -> + deviceHardwareRepository.observeDeviceHardware(hardwareModel, target) + } ?: flowOf(null) + } + + private val firmwareUpdateInputs = + combine( + connectionState, + nodeRepository.myId, + nodeRepository.myNodeInfo, + firmwareReleaseRepository.stableRelease, + radioPrefs.devAddr, + ) { state, nodeIdentity, myNode, stableRelease, address -> + FirmwareUpdateInputs( + connectionState = state, + nodeIdentity = nodeIdentity, + currentVersion = myNode?.firmwareVersion, + // A stale (or failed-to-refresh) release catalog must not prompt users. The repository updates this + // timestamp only when it writes a current catalog; its bundled seed and a successful refresh are + // both valid sources, while an old cache fails closed. + stableRelease = + stableRelease?.takeIf { it.lastUpdated >= nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds }, + address = address, + ) + } + + private val firmwareUpdateCandidate = + combine(firmwareUpdateInputs, localHardware) { inputs, hardware -> + val state = inputs.connectionState + if (state !is ConnectionState.Connected) return@combine null + val transport = inputs.address?.firstOrNull()?.toFirmwareUpdateTransport() ?: return@combine null + val stableRelease = inputs.stableRelease ?: return@combine null + val deviceHardware = hardware ?: return@combine null + FirmwareUpdateCandidate( + nodeIdentity = inputs.nodeIdentity, + currentVersion = inputs.currentVersion, + stableRelease = stableRelease, + hardware = deviceHardware, + transport = transport, + ) + } + + val firmwareUpdateNotice: StateFlow = + firmwareUpdateCandidate + .flatMapLatest { candidate -> + candidate?.let { + flow { + val releaseTargets = + firmwareReleaseRepository.getManifestTargets(it.stableRelease) ?: emptySet() + emit( + FirmwareUpdateNoticePolicy.createNotice( + nodeIdentity = it.nodeIdentity, + currentVersion = it.currentVersion, + stableVersion = it.stableRelease.id, + hardware = it.hardware, + transport = it.transport, + releaseTargets = releaseTargets, + ), + ) + } + .catch { emit(null) } + } ?: flowOf(null) + } + .distinctUntilChanged() + .stateInWhileSubscribed(initialValue = null) + + init { + firmwareUpdateNotice + .map { notice -> + notice?.takeIf { + FirmwareUpdateNoticePolicy.shouldSchedule( + it.notificationKey, + uiPrefs.firmwareUpdateNotificationKeys.value, + ) && it.notificationKey !in scheduledFirmwareUpdateNotificationKeys + } + } + .filterNotNull() + .onEach { notice -> + val message = + when (notice.destination) { + org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate -> + getStringSuspend( + Res.string.firmware_update_notification_android, + notice.currentVersion, + notice.stableVersion, + ) + + org.meshtastic.core.model.FirmwareUpdateDestination.MeshtasticFlasher -> + getStringSuspend( + Res.string.firmware_update_notification_flasher, + notice.currentVersion, + notice.stableVersion, + ) + } + if ( + notificationManager.dispatch( + Notification( + id = notice.notificationKey.hashCode(), + title = getStringSuspend(Res.string.firmware_update_available), + message = message, + type = Notification.Type.Info, + category = Notification.Category.NodeEvent, + deepLinkUri = + if ( + notice.destination == + org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate + ) { + "meshtastic:///firmware/update" + } else { + "https://flasher.meshtastic.org" + }, + ), + ) + ) { + scheduledFirmwareUpdateNotificationKeys += notice.notificationKey + uiPrefs.recordFirmwareUpdateNotificationKey(notice.notificationKey) + } + } + .launchIn(viewModelScope) + } +} + +private data class FirmwareUpdateInputs( + val connectionState: ConnectionState, + val nodeIdentity: String?, + val currentVersion: String?, + val stableRelease: FirmwareRelease?, + val address: String?, +) + +private data class FirmwareUpdateCandidate( + val nodeIdentity: String?, + val currentVersion: String?, + val stableRelease: FirmwareRelease, + val hardware: DeviceHardware, + val transport: FirmwareUpdateTransport, +) + +private fun Char.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) { + 'x' -> FirmwareUpdateTransport.Bluetooth + 's' -> FirmwareUpdateTransport.Serial + 't' -> FirmwareUpdateTransport.Tcp + else -> null } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt index 2c1c121c97..33fd9f88ca 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt @@ -20,23 +20,33 @@ import app.cash.turbine.test import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every -import dev.mokkery.matcher.any import dev.mokkery.mock -import dev.mokkery.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.FirmwareUpdateDestination +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.testing.FakeDeviceHardwareRepository +import org.meshtastic.core.testing.FakeFirmwareReleaseRepository import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioPrefs import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.FakeUiPrefs +import org.meshtastic.core.testing.TestDataFactory +import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.User import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -51,14 +61,33 @@ class ConnectionsViewModelTest { private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository = FakeServiceRepository() private val nodeRepository = FakeNodeRepository() - private val uiPrefs: UiPrefs = mock(MockMode.autofill) + private val uiPrefs = FakeUiPrefs() + private val deviceHardwareRepository = FakeDeviceHardwareRepository() + private val firmwareReleaseRepository = FakeFirmwareReleaseRepository() + private val radioPrefs = FakeRadioPrefs() + private val dispatchedNotifications = mutableListOf() + private var notificationsCanBeScheduled = true + private val notificationManager = + object : NotificationManager { + override fun dispatch(notification: Notification): Boolean { + if (notificationsCanBeScheduled) dispatchedNotifications += notification + return notificationsCanBeScheduled + } + + override fun cancel(id: Int) = Unit + + override fun cancelAll() = Unit + } @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) + dispatchedNotifications.clear() + notificationsCanBeScheduled = true every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) - every { uiPrefs.hasShownNotPairedWarning } returns MutableStateFlow(false) + uiPrefs.hasShownNotPairedWarning.value = false + uiPrefs.firmwareUpdateNotificationKeys.value = emptySet() viewModel = ConnectionsViewModel( @@ -66,6 +95,10 @@ class ConnectionsViewModelTest { serviceRepository = serviceRepository, nodeRepository = nodeRepository, uiPrefs = uiPrefs, + deviceHardwareRepository = deviceHardwareRepository, + firmwareReleaseRepository = firmwareReleaseRepository, + radioPrefs = radioPrefs, + notificationManager = notificationManager, ) } @@ -81,12 +114,10 @@ class ConnectionsViewModelTest { @Test fun `suppressNoPairedWarning updates state and prefs`() { - every { uiPrefs.setHasShownNotPairedWarning(any()) } returns Unit - viewModel.suppressNoPairedWarning() assertEquals(true, viewModel.hasShownNotPairedWarning.value) - verify { uiPrefs.setHasShownNotPairedWarning(true) } + assertEquals(true, uiPrefs.hasShownNotPairedWarning.value) } @Test @@ -139,6 +170,105 @@ class ConnectionsViewModelTest { } } + @Test + fun `connected older known node exposes Android firmware update notice`() = runTest { + val hardwareModel = HardwareModel.TBEAM.value + val target = "tbeam" + deviceHardwareRepository.setHardware( + hwModel = hardwareModel, + target = target, + device = DeviceHardware(architecture = "esp32", platformioTarget = target), + ) + nodeRepository.setMyId("!local") + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target)) + nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM))) + radioPrefs.setDevAddr("x:connected") + firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target)) + firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0")) + serviceRepository.setConnectionState(ConnectionState.Connected) + + advanceUntilIdle() + + val notice = assertNotNull(viewModel.firmwareUpdateNotice.value) + assertEquals("2.7.0", notice.currentVersion) + assertEquals("2.8.0", notice.stableVersion) + assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination) + assertEquals(1, dispatchedNotifications.size) + assertEquals(setOf(notice.notificationKey), uiPrefs.firmwareUpdateNotificationKeys.value) + assertEquals("Firmware update available", dispatchedNotifications.single().title) + assertEquals(Notification.Type.Info, dispatchedNotifications.single().type) + assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri) + } + + @Test + fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest { + val hardwareModel = HardwareModel.TBEAM.value + val target = "tbeam" + notificationsCanBeScheduled = false + deviceHardwareRepository.setHardware( + hwModel = hardwareModel, + target = target, + device = DeviceHardware(architecture = "esp32", platformioTarget = target), + ) + nodeRepository.setMyId("!local") + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target)) + nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM))) + radioPrefs.setDevAddr("x:connected") + firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target)) + firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0")) + serviceRepository.setConnectionState(ConnectionState.Connected) + + advanceUntilIdle() + + assertNotNull(viewModel.firmwareUpdateNotice.value) + assertEquals(emptyList(), dispatchedNotifications) + assertEquals(emptySet(), uiPrefs.firmwareUpdateNotificationKeys.value) + } + + @Test + fun `stale firmware catalog does not expose a notice`() = runTest { + val hardwareModel = HardwareModel.TBEAM.value + val target = "tbeam" + deviceHardwareRepository.setHardware( + hwModel = hardwareModel, + target = target, + device = DeviceHardware(architecture = "esp32", platformioTarget = target), + ) + nodeRepository.setMyId("!local") + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target)) + nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM))) + radioPrefs.setDevAddr("x:connected") + firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0", lastUpdated = 0)) + serviceRepository.setConnectionState(ConnectionState.Connected) + + advanceUntilIdle() + + assertEquals(null, viewModel.firmwareUpdateNotice.value) + } + + @Test + fun `stable manifest missing the connected target does not expose a notice`() = runTest { + val hardwareModel = HardwareModel.TBEAM.value + val target = "tbeam" + deviceHardwareRepository.setHardware( + hwModel = hardwareModel, + target = target, + device = DeviceHardware(architecture = "esp32", platformioTarget = target), + ) + nodeRepository.setMyId("!local") + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target)) + nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM))) + radioPrefs.setDevAddr("x:connected") + firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf("t-echo")) + firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0")) + serviceRepository.setConnectionState(ConnectionState.Connected) + + advanceUntilIdle() + + assertEquals(null, viewModel.firmwareUpdateNotice.value) + assertEquals(emptyList(), dispatchedNotifications) + } + /** * Cross-track contract pin: Track A (MeshConnectionManagerImpl.runSiblingHandshakeRecovery) writes the literal * "Reconnecting…" (with U+2026) to ServiceRepository.connectionProgress. This constant is what Track C compares diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 9a003e63b3..5a1ad7155b 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -62,7 +62,7 @@ class DesktopNotificationManager( */ val fallbackNotifications: SharedFlow = _fallbackNotifications.asSharedFlow() - override fun dispatch(notification: Notification) { + override fun dispatch(notification: Notification): Boolean { val enabled = when (notification.category) { Notification.Category.Message -> prefs.messagesEnabled.value @@ -74,7 +74,7 @@ class DesktopNotificationManager( } Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" } - if (!enabled) return + if (!enabled) return false scope.launch { val success = nativeSender.send(notification) @@ -83,6 +83,7 @@ class DesktopNotificationManager( emitFallback(notification) } } + return true } private fun emitFallback(notification: Notification) { 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 0a535530a3..c2fa20be72 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 @@ -24,6 +24,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,7 +32,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -45,6 +49,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect @@ -53,6 +58,8 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.FirmwareUpdateDestination +import org.meshtastic.core.model.FirmwareUpdateNotice import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.navigation.FirmwareRoute import org.meshtastic.core.navigation.Route @@ -65,6 +72,11 @@ 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 +import org.meshtastic.core.resources.firmware_update_available +import org.meshtastic.core.resources.firmware_update_notification_android +import org.meshtastic.core.resources.firmware_update_notification_flasher +import org.meshtastic.core.resources.firmware_update_open +import org.meshtastic.core.resources.firmware_update_open_flasher import org.meshtastic.core.resources.no_device_selected import org.meshtastic.core.resources.open_bluetooth_settings import org.meshtastic.core.resources.open_wifi_settings @@ -79,6 +91,7 @@ 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.icon.SystemUpdate import org.meshtastic.core.ui.util.LocalEventBranding import org.meshtastic.core.ui.util.PermissionStatus import org.meshtastic.core.ui.util.hasEnded @@ -127,6 +140,7 @@ fun ConnectionsScreen( val connectionStatus by connectionsViewModel.connectionStatus.collectAsStateWithLifecycle() val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle() + val firmwareUpdateNotice by connectionsViewModel.firmwareUpdateNotice.collectAsStateWithLifecycle() val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle() val sessionAuthorized by connectionsViewModel.sessionAuthorized.collectAsStateWithLifecycle() @@ -156,6 +170,7 @@ fun ConnectionsScreen( val wifiUnavailable = isWifiUnavailable() val openBluetoothSettings = rememberOpenBluetoothSettings() val openWifiSettings = rememberOpenWifiSettings() + val uriHandler = LocalUriHandler.current // Auto-start BLE discovery when the screen is visible (lifecycle ≥ STARTED) and the user has previously opted in. // ScannerViewModel skips screen-entry discovery when a selected device can reconnect through the transport's @@ -302,6 +317,21 @@ fun ConnectionsScreen( } } + firmwareUpdateNotice?.let { notice -> + FirmwareUpdateNoticeCard( + notice = notice, + onAction = { + when (notice.destination) { + FirmwareUpdateDestination.AndroidUpdate -> + onConfigNavigate(FirmwareRoute.FirmwareUpdate) + + FirmwareUpdateDestination.MeshtasticFlasher -> + uriHandler.openUri("https://flasher.meshtastic.org") + } + }, + ) + } + // A device stranded in bootloader mode by an interrupted update can be re-flashed without // reconnecting first. Shown only while disconnected so the Firmware screen enters its recovery // path (it uses the live connection when connected); cleared automatically once the device @@ -463,6 +493,57 @@ fun ConnectionsScreen( } } +/** Informational, non-dismissible nudge for a connected device with a newer stable firmware release. */ +@Composable +private fun FirmwareUpdateNoticeCard(notice: FirmwareUpdateNotice, onAction: () -> Unit) { + val actionLabel = + stringResource( + when (notice.destination) { + FirmwareUpdateDestination.AndroidUpdate -> Res.string.firmware_update_open + FirmwareUpdateDestination.MeshtasticFlasher -> Res.string.firmware_update_open_flasher + }, + ) + val message = + stringResource( + when (notice.destination) { + FirmwareUpdateDestination.AndroidUpdate -> Res.string.firmware_update_notification_android + FirmwareUpdateDestination.MeshtasticFlasher -> Res.string.firmware_update_notification_flasher + }, + notice.currentVersion, + notice.stableVersion, + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = MeshtasticIcons.SystemUpdate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text( + text = stringResource(Res.string.firmware_update_available), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = message, + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(modifier = Modifier.padding(top = 12.dp), onClick = onAction) { + Icon(imageVector = MeshtasticIcons.SystemUpdate, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(actionLabel) + } + } + } + } +} + /** Body for the CONNECTED state — sits inside the shared outer Card in [ConnectionsScreen]. */ @Composable private fun ConnectedDeviceContent(