mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-16 15:19:36 -04:00
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:
23 files changed
+838
-20
No files matched your search
+19
@@ -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()
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+24
@@ -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 = "")
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
+113
@@ -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)))
|
||||
}
|
||||
}
|
||||
+4
@@ -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)
|
||||
|
||||
+6
@@ -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()
|
||||
}
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+15
-4
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-3
@@ -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> =
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
}
|
||||
+138
-8
@@ -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) {
|
||||
|
||||
+81
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user