Add firmware update notice (#6309)

Co-authored-by: Benjamin Faershtein <benjaminfaershtein@Benjamins-MacBook-Pro-2.local>
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-19 14:31:25 -05:00
1 parent 5d84472f07
commit 91d2f7660b
23 files changed
+838 -20

No files matched your search

@@ -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<String, Set<String>>()
/**
* 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<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.NIGHTLY)
override suspend fun getManifestTargets(release: FirmwareRelease): Set<String>? {
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<FirmwareRelease?> = staleWhileRevalidateFlow(
loadFromCache = {
ensureSeeded()
@@ -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")
@@ -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 {
@@ -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<NetworkDeviceHardware> = 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))
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<FirmwareTarget> = emptyList())
/** One firmware target declared by a [FirmwareReleaseManifest]. */
@Serializable data class FirmwareTarget(val board: String = "", val platform: String = "")
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>,
): 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<String>): 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<ParsedVersion> {
val normalized: String = "$major.$minor.$patch"
override fun compareTo(other: ParsedVersion): Int =
compareValuesBy(this, other, ParsedVersion::major, ParsedVersion::minor, ParsedVersion::patch)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)))
}
}
@@ -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() }
@@ -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 {
@@ -168,6 +168,30 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_SELECTED_CONNECTION_TRANSPORT] = type.name } }
}
override val firmwareUpdateNotificationKeys: StateFlow<Set<String>> =
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<Boolean> =
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 }
@@ -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,
)
}
}
@@ -146,6 +146,12 @@ interface UiPrefs {
fun setSelectedConnectionTransport(type: DeviceType)
/** Keys for firmware-update notifications already scheduled on this device. */
val firmwareUpdateNotificationKeys: StateFlow<Set<String>>
/** Records a notification key after the platform notification has been scheduled successfully. */
fun recordFirmwareUpdateNotificationKey(key: String)
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
@@ -32,6 +32,12 @@ interface FirmwareReleaseRepository {
*/
val nightlyRelease: Flow<FirmwareRelease?>
/**
* 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<String>?
/** Invalidates the local cache of firmware releases. */
suspend fun invalidateCache()
}
@@ -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)
@@ -630,6 +630,7 @@
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_archive_missing_target">No firmware for %2$s (%3$s) was found in \"%1$s\".</string>
<string name="firmware_update_available">Firmware update available</string>
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_confirm_file_button">Start Flashing</string>
@@ -669,6 +670,10 @@
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
<string name="firmware_update_notification_android">%1$s is installed. Stable %2$s is available. Open Firmware Updates when you're ready.</string>
<string name="firmware_update_notification_flasher">%1$s is installed. Stable %2$s is available. Open Meshtastic Flasher to update this hardware.</string>
<string name="firmware_update_open">Open Firmware Updates</string>
<string name="firmware_update_open_flasher">Open Meshtastic Flasher</string>
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
<string name="firmware_update_ota_unsupported_reason">This device\'s OTA loader rejected the requested update method: %1$s</string>
<string name="firmware_update_rak4631_bootloader_hint">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.</string>
@@ -1829,4 +1834,3 @@
<string name="zh_CN" translatable="false">简体中文</string>
<string name="zh_TW" translatable="false">繁體中文</string>
</resources>
@@ -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))
}
/**
@@ -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
@@ -175,6 +175,12 @@ class FakeUiPrefs : UiPrefs {
selectedConnectionTransport.value = type
}
override val firmwareUpdateNotificationKeys = MutableStateFlow<Set<String>>(emptySet())
override fun recordFirmwareUpdateNotificationKey(key: String) {
firmwareUpdateNotificationKeys.value += key
}
private val nodeLocationEnabled = mutableMapOf<Int, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
@@ -33,16 +33,22 @@ class FakeFirmwareReleaseRepository :
private val _stableRelease = mutableStateFlow<FirmwareRelease?>(null)
private val _alphaRelease = mutableStateFlow<FirmwareRelease?>(null)
private val _nightlyRelease = mutableStateFlow<FirmwareRelease?>(null)
private val manifestTargets = mutableMapOf<String, Set<String>?>()
override val stableRelease: Flow<FirmwareRelease?> = _stableRelease
override val alphaRelease: Flow<FirmwareRelease?> = _alphaRelease
override val nightlyRelease: Flow<FirmwareRelease?> = _nightlyRelease
override suspend fun getManifestTargets(release: FirmwareRelease): Set<String>? = 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<String>?) {
manifestTargets[releaseId] = targets
}
}
@@ -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<String>()
val localConfig: StateFlow<LocalConfig> =
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<FirmwareUpdateNotice?> =
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
}
@@ -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<Notification>()
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
@@ -62,7 +62,7 @@ class DesktopNotificationManager(
*/
val fallbackNotifications: SharedFlow<ComposeNotification> = _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) {
@@ -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(