Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
*/
package org.meshtastic.core.data.datasource

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
Expand All @@ -35,6 +38,10 @@ class EventFirmwareEditionLocalDataSource(
suspend fun getByEdition(edition: String): EventFirmwareEditionEntity? =
withContext(dispatchers.io) { dao.getByEdition(edition) }

@OptIn(ExperimentalCoroutinesApi::class)
fun observeByEdition(edition: String): Flow<EventFirmwareEditionEntity?> =
dbManager.currentDb.flatMapLatest { db -> db.eventFirmwareEditionDao().observeByEdition(edition) }

suspend fun upsertAll(editions: List<EventFirmwareEditionEntity>) {
withContext(dispatchers.io) { dbManager.withDb { it.eventFirmwareEditionDao().upsertAll(editions) } }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,18 +493,10 @@ class MeshConfigFlowManagerImpl(
null
}

// Whether to silence new-node notifications is a read-decide-write over two preferences, so it lives behind one
// atomic prefs operation rather than being assembled from setter calls here.
private fun applyEventFirmwareNotificationDefaults(edition: FirmwareEdition) {
if (edition != FirmwareEdition.VANILLA) {
if (!notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
notificationPrefs.setNodeEventsEnabled(false)
notificationPrefs.setNodeEventsAutoDisabledForEvent(true)
}
} else {
if (notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
notificationPrefs.setNodeEventsEnabled(true)
notificationPrefs.setNodeEventsAutoDisabledForEvent(false)
}
}
notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = edition != FirmwareEdition.VANILLA)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
package org.meshtastic.core.data.repository

import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -79,12 +84,26 @@ class EventFirmwareRepositoryImpl(

override suspend fun getEdition(editionName: String): EventFirmwareEdition? = withContext(dispatchers.io) {
ensureSeeded()
maybeRefresh(maxWaitMs = NETWORK_REFRESH_TIMEOUT_MS)
localDataSource.getByEdition(editionName)?.asExternalModel()
}

override fun observeEdition(editionName: String): Flow<EventFirmwareEdition?> = flow {
ensureSeeded()
// Don't wait β€” the emissions below carry whatever the refresh writes, so blocking would only delay the
// cached value the caller could already be showing.
maybeRefresh(maxWaitMs = 0)
emitAll(localDataSource.observeByEdition(editionName).map { it?.asExternalModel() })
}
.flowOn(dispatchers.io)

/** Refreshes if the cache is stale and the retry cooldown has elapsed. See [lastAttemptMillis] for the cooldown. */
private suspend fun maybeRefresh(maxWaitMs: Long) {
val stale = nowMillis - lastRefreshMillis > CACHE_EXPIRATION_TIME_MS
val retryCooldownElapsed = nowMillis - lastAttemptMillis > REFRESH_RETRY_COOLDOWN_MS
if (stale && retryCooldownElapsed) {
refresher.refresh(maxWaitMs = NETWORK_REFRESH_TIMEOUT_MS)
refresher.refresh(maxWaitMs = maxWaitMs)
}
localDataSource.getByEdition(editionName)?.asExternalModel()
}

/** Seeds the table from the bundled snapshot if empty (fresh install, data clear). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,50 +821,34 @@ class MeshConfigFlowManagerImplTest {
}

// ---------- Event firmware notification defaults ----------
//
// The decision itself (which preference wins, and when) is a single atomic prefs operation and is covered in
// NotificationPrefsTest. These only assert that the config flow delegates with the right firmware classification.

@Test
fun `handleMyInfo disables node notifications for event firmware`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)

val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
handleMyInfo(eventMyInfo)
advanceUntilIdle()

verify { notificationPrefs.setNodeEventsEnabled(false) }
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(true) }
}

@Test
fun `handleMyInfo does not re-disable if already auto-disabled`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)

val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
handleMyInfo(eventMyInfo)
fun `handleMyInfo applies the event node-event default for event firmware`() = testScope.runTest {
handleMyInfo(protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON))
advanceUntilIdle()

verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
verify { notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true) }
}

@Test
fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)

fun `handleMyInfo applies the vanilla node-event default for vanilla firmware`() = testScope.runTest {
handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()

verify { notificationPrefs.setNodeEventsEnabled(true) }
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(false) }
verify { notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = false) }
}

@Test
fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)

handleMyInfo(protoMyNodeInfo)
fun `handleMyInfo treats DIY_EDITION as event firmware`() = testScope.runTest {
// Any non-vanilla edition counts, including the DIY catch-all β€” it has no branding metadata but the same
// notification noise problem.
handleMyInfo(protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DIY_EDITION))
advanceUntilIdle()

verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsAutoDisabledForEvent(any()) }
verify { notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true) }
}

// ---------- onHandshakeProgress ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
*/
package org.meshtastic.core.data.repository

import app.cash.turbine.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okio.Buffer
Expand All @@ -42,7 +44,9 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Duration.Companion.seconds

class EventFirmwareRepositoryImplTest {

Expand Down Expand Up @@ -129,6 +133,36 @@ class EventFirmwareRepositoryImplTest {
assertNull(repository.getEdition("VANILLA"))
}

@Test
fun observeEditionReEmitsWhenTheCacheIsRefreshed() = runBlocking {
// The point of the observable: an edition absent at connect time β€” because the bundled seed predates it β€”
// must reach the UI when the refresh lands, without the caller re-subscribing. The new value therefore has to
// arrive *through the refresh path*; writing it into the cache by hand would still pass if observeEdition
// stopped refreshing at all.
seed.editions = listOf(edition("HAMVENTION"))
api.response = EventFirmwareResponse(editions = listOf(edition("HAMVENTION"), edition("DEFCON")))

repository.observeEdition("DEFCON").test(timeout = EMISSION_TIMEOUT) {
// The first item is null only if the refresh is still in flight when collection starts; under
// Dispatchers.Unconfined it may already have completed inline. Which of the two happens is timing, not
// contract, so accept either and assert on the value that matters.
val observed = assertNotNull(awaitItem() ?: awaitItem(), "expected the refreshed DEFCON edition")
// DEFCON exists only in the network response, never in the seed, so observing it at all proves the value
// arrived via refresh β†’ cache write β†’ emission.
assertEquals("defcon", observed.displayName)
cancelAndIgnoreRemainingEvents()
}
// Collection is what drove that fetch: nothing else in this test touches the network or the cache.
assertEquals(1, api.eventFirmwareCalls)
}

@Test
fun observeEditionEmitsNullForUnknownEdition() = runBlocking {
seed.editions = listOf(edition("HAMVENTION"))

assertNull(repository.observeEdition("VANILLA").first())
}

@Test
fun absentAssetYieldsNullWithoutCrashing() = runBlocking {
seed.present = false
Expand Down Expand Up @@ -231,4 +265,9 @@ class EventFirmwareRepositoryImplTest {

assertEquals("hamvention", restarted.getEdition("HAMVENTION")?.displayName)
}

private companion object {
/** Room's invalidation tracker plus the fake network round-trip; generous, since it only bounds a failure. */
private val EMISSION_TIMEOUT = 10.seconds
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.EventFirmwareEditionEntity

@Dao
Expand All @@ -28,6 +29,14 @@ interface EventFirmwareEditionDao {
@Query("SELECT * FROM event_firmware_edition WHERE edition = :edition")
suspend fun getByEdition(edition: String): EventFirmwareEditionEntity?

/**
* Observes [edition], re-emitting whenever the table changes. This is what lets a background manifest refresh reach
* already-visible event branding: a one-shot read resolves once when the connection is established, so metadata
* that lands afterwards would sit in the table unseen until the user reconnected.
*/
@Query("SELECT * FROM event_firmware_edition WHERE edition = :edition LIMIT 1")
fun observeByEdition(edition: String): Flow<EventFirmwareEditionEntity?>

/**
* Deletes rows whose edition is not in [keep]. WARNING: `NOT IN ()` is always true in SQLite, so an **empty**
* [keep] deletes every row β€” call sites must guard against passing an empty list (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ class NotificationPrefsImpl(
scope.launch { dataStore.edit { it[KEY_NODE_EVENTS_AUTO_DISABLED] = disabled } }
}

override fun applyEventFirmwareNodeEventDefault(isEventFirmware: Boolean) {
// One edit block: DataStore serializes edits, so the values read here are the same snapshot written below.
// Reading the StateFlows and calling the setters instead would race β€” they lag these writes.
scope.launch {
dataStore.edit { prefs ->
val enabled = prefs[KEY_NODE_EVENTS_ENABLED] ?: true
val autoDisabled = prefs[KEY_NODE_EVENTS_AUTO_DISABLED] ?: false
when {
// Only claim the restore if node events were actually on β€” otherwise the vanilla branch would
// later enable a preference the user turned off themselves.
isEventFirmware && !autoDisabled && enabled -> {
prefs[KEY_NODE_EVENTS_ENABLED] = false
prefs[KEY_NODE_EVENTS_AUTO_DISABLED] = true
}

!isEventFirmware && autoDisabled -> {
prefs[KEY_NODE_EVENTS_ENABLED] = true
prefs[KEY_NODE_EVENTS_AUTO_DISABLED] = false
}
}
}
}
}

override val lowBatteryEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_LOW_BATTERY_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ class NotificationPrefsTest {
assertFalse(notificationPrefs.nodeEventsEnabled.value)
}

// ---------- applyEventFirmwareNodeEventDefault ----------

@Test
fun `event firmware disables node events and claims the restore`() = testScope.runTest {
notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)

assertFalse(notificationPrefs.nodeEventsEnabled.value)
assertTrue(notificationPrefs.nodeEventsAutoDisabledForEvent.value)
}

@Test
fun `event firmware leaves an already-off preference alone and claims nothing`() = testScope.runTest {
// The user turned node events off themselves. Claiming the restore here would make the next vanilla
// connection switch them back on, discarding a choice we never made.
notificationPrefs.setNodeEventsEnabled(false)

notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)

assertFalse(notificationPrefs.nodeEventsEnabled.value)
assertFalse(notificationPrefs.nodeEventsAutoDisabledForEvent.value)
}

@Test
fun `vanilla firmware restores node events only when the restore was claimed`() = testScope.runTest {
notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)
assertFalse(notificationPrefs.nodeEventsEnabled.value)

notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = false)

assertTrue(notificationPrefs.nodeEventsEnabled.value)
assertFalse(notificationPrefs.nodeEventsAutoDisabledForEvent.value)
}

@Test
fun `vanilla firmware does not enable node events the user had turned off`() = testScope.runTest {
// The full round trip of the bug this guards: off by the user, connect to event firmware, back to vanilla.
notificationPrefs.setNodeEventsEnabled(false)

notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)
notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = false)

assertFalse(notificationPrefs.nodeEventsEnabled.value)
assertFalse(notificationPrefs.nodeEventsAutoDisabledForEvent.value)
}

@Test
fun `repeated event firmware connections do not re-disable a manual re-enable`() = testScope.runTest {
notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)
// User re-enables mid-event; the restore is still claimed, so reconnects must respect their choice.
notificationPrefs.setNodeEventsEnabled(true)

notificationPrefs.applyEventFirmwareNodeEventDefault(isEventFirmware = true)

assertTrue(notificationPrefs.nodeEventsEnabled.value)
}

@Test
fun `setting lowBatteryEnabled updates preference`() = testScope.runTest {
notificationPrefs.setLowBatteryEnabled(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,19 @@ interface NotificationPrefs {

val nodeEventsAutoDisabledForEvent: StateFlow<Boolean>

/**
* Applies the node-event notification default for the connected firmware, as a single atomic update.
*
* The decision reads both [nodeEventsEnabled] and [nodeEventsAutoDisabledForEvent] and conditionally writes both,
* so it cannot be expressed as separate reads and setter calls: the setters are asynchronous and the StateFlows lag
* them, so a caller doing it by hand can read stale values or clobber a concurrent user toggle.
*
* On event firmware, node events are disabled and the restore is claimed β€” but only if they were actually on, so a
* user who had already turned them off is not re-enabled later. On vanilla firmware, a previously claimed restore
* is honored and released.
*/
fun applyEventFirmwareNodeEventDefault(isEventFirmware: Boolean)

fun setNodeEventsAutoDisabledForEvent(disabled: Boolean)

val lowBatteryEnabled: StateFlow<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@
*/
package org.meshtastic.core.repository

import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.model.EventFirmwareEdition

/** Event-firmware display metadata, seeded from the bundled `event_firmware.json` snapshot. */
interface EventFirmwareRepository {
/** Metadata for [editionName] (a `FirmwareEdition` enum name), or `null` if it is not a known event edition. */
suspend fun getEdition(editionName: String): EventFirmwareEdition?

/**
* Metadata for [editionName], re-emitting when the cache is refreshed from the network.
*
* Prefer this over [getEdition] for anything long-lived on screen. A one-shot read resolves once β€” when the device
* connects β€” so an edition added to the hosted manifest after that point stays invisible until the user reconnects.
* The flow triggers a refresh on collection and then re-emits whatever lands.
*/
fun observeEdition(editionName: String): Flow<EventFirmwareEdition?>
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ class FakeNotificationPrefs : NotificationPrefs {
nodeEventsAutoDisabledForEvent.value = disabled
}

override fun applyEventFirmwareNodeEventDefault(isEventFirmware: Boolean) {
when {
isEventFirmware && !nodeEventsAutoDisabledForEvent.value && nodeEventsEnabled.value -> {
nodeEventsEnabled.value = false
nodeEventsAutoDisabledForEvent.value = true
}

!isEventFirmware && nodeEventsAutoDisabledForEvent.value -> {
nodeEventsEnabled.value = true
nodeEventsAutoDisabledForEvent.value = false
}
}
}

override val lowBatteryEnabled = MutableStateFlow(true)

override fun setLowBatteryEnabled(enabled: Boolean) {
Expand Down
Loading