Skip to content
Open
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
23 changes: 23 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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.prefs.notification

import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.repository.LiveUpdateMode
import org.meshtastic.core.repository.LiveUpdatePrefs

@Single
class LiveUpdatePrefsImpl(private val dataStore: UiDataStore, private val scope: ApplicationCoroutineScope) :
LiveUpdatePrefs {

override val mode: StateFlow<LiveUpdateMode> =
dataStore.data
.map { LiveUpdateMode.decode(it[KEY_LIVE_UPDATE_MODE]) }
.stateIn(scope, SharingStarted.Eagerly, LiveUpdateMode.Off)

override fun setMode(mode: LiveUpdateMode) {
scope.launch { dataStore.edit { it[KEY_LIVE_UPDATE_MODE] = LiveUpdateMode.encode(mode) } }
}

private companion object {
val KEY_LIVE_UPDATE_MODE = stringPreferencesKey("live_update_mode")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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.prefs.notification

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.FileSystem
import okio.Path
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.prefs.di.asUiDataStore
import org.meshtastic.core.repository.LiveUpdateMode
import org.meshtastic.core.repository.LiveUpdatePrefs
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.uuid.Uuid

class LiveUpdatePrefsTest {
private lateinit var tmpDir: Path
private lateinit var dataStore: DataStore<Preferences>
private lateinit var prefs: LiveUpdatePrefs

private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)

@BeforeTest
fun setup() {
tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "liveUpdatePrefsTest-${Uuid.random()}"
FileSystem.SYSTEM.createDirectories(tmpDir)
dataStore =
PreferenceDataStoreFactory.createWithPath(
scope = testScope,
produceFile = { tmpDir / "test.preferences_pb" },
)
val appScope = object : ApplicationCoroutineScope, CoroutineScope by CoroutineScope(testDispatcher) {}
prefs = LiveUpdatePrefsImpl(dataStore.asUiDataStore(), appScope)
}

@AfterTest
fun tearDown() {
FileSystem.SYSTEM.deleteRecursively(tmpDir)
}

@Test fun `mode defaults to off`() = testScope.runTest { assertEquals(LiveUpdateMode.Off, prefs.mode.value) }

@Test
fun `each mode round trips`() = testScope.runTest {
prefs.setMode(LiveUpdateMode.MeshStatus)
assertEquals(LiveUpdateMode.MeshStatus, prefs.mode.value)

prefs.setMode(LiveUpdateMode.TrackNode(-12345))
assertEquals(LiveUpdateMode.TrackNode(-12345), prefs.mode.value)

prefs.setMode(LiveUpdateMode.Off)
assertEquals(LiveUpdateMode.Off, prefs.mode.value)
}

@Test
fun `an unreadable stored value decodes to off`() {
assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode("node:abc"))
assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode("something"))
assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode(null))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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.repository

import kotlinx.coroutines.flow.StateFlow

/** What the user-started Live Update shows. One mode at a time; [Off] leaves the service notification as it is. */
sealed interface LiveUpdateMode {
data object Off : LiveUpdateMode

/** Connection, nodes online, unread messages and the connected radio's battery. */
data object MeshStatus : LiveUpdateMode

/** One node: its battery, signal, when it was last heard, hops and distance. */
data class TrackNode(val nodeNum: Int) : LiveUpdateMode

companion object {
private const val OFF = "off"
private const val MESH_STATUS = "mesh"
private const val TRACK_NODE_PREFIX = "node:"

fun encode(mode: LiveUpdateMode): String = when (mode) {
Off -> OFF
MeshStatus -> MESH_STATUS
is TrackNode -> TRACK_NODE_PREFIX + mode.nodeNum
}

/** Anything unreadable decodes to [Off], so a corrupt value can never leave a promoted notification up. */
fun decode(value: String?): LiveUpdateMode = when {
value == MESH_STATUS -> MeshStatus

value?.startsWith(TRACK_NODE_PREFIX) == true ->
value.removePrefix(TRACK_NODE_PREFIX).toIntOrNull()?.let(::TrackNode) ?: Off

else -> Off
}
}
}

/** The persisted [LiveUpdateMode]. Off by default. */
interface LiveUpdatePrefs {
val mode: StateFlow<LiveUpdateMode>

fun setMode(mode: LiveUpdateMode)
}
23 changes: 23 additions & 0 deletions core/resources/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,29 @@
<string name="licensed_mode_enable_warning_signed">Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — but it is digitally signed, letting other nodes verify it came from you. Your node number may change once to match your identity key, so favorites, message history, remote admin, and other nodes may initially treat it as new; you remain responsible for meeting your amateur radio license requirements and local regulations.</string>
<string name="lightning_distance">Storm Distance</string>
<string name="lightning_strikes_1h">Lightning (1h)</string>
<!-- LIVE -->
<string name="live_update">Live Update</string>
<string name="live_update_direct">Direct</string>
<string name="live_update_distance">%1$s away</string>
<string name="live_update_heard">Heard %1$s</string>
<string name="live_update_heard_now">Heard just now</string>
<string name="live_update_hops">Hops away: %1$d</string>
<string name="live_update_mesh_status">Mesh status</string>
<string name="live_update_mesh_status_summary">Connection, nodes online, unread messages and your radio's battery</string>
<string name="live_update_no_nodes">No nodes to track</string>
<string name="live_update_nodes_online">%1$d of %2$d nodes online</string>
<string name="live_update_off_summary">Show the mesh or one node in the status bar</string>
<string name="live_update_promotion_off">Live Updates are turned off for Meshtastic in system settings</string>
<string name="live_update_search_nodes">Search nodes</string>
<string name="live_update_signal">SNR %1$s, RSSI %2$s</string>
<string name="live_update_start">Start</string>
<string name="live_update_stop">Stop</string>
<string name="live_update_switch">Switch</string>
<string name="live_update_track_in">Track in Live Update</string>
<string name="live_update_track_node">Track a node</string>
<string name="live_update_track_node_summary">One node's battery, signal, last heard, hops and distance</string>
<string name="live_update_tracking">Tracking %1$s</string>
<string name="live_update_unread">%1$d unread</string>
<!-- LOAD -->
<string name="load">Load</string>
<string name="load_15_min">Load 15m</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.testing.FakeLiveUpdatePrefs
import org.meshtastic.core.testing.FakeLocaleUnitsProvider
import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.ChannelSet
Expand Down Expand Up @@ -124,6 +127,9 @@ class MeshNotificationManagerImplConversationTest {
radioConfigRepository = lazy { radioConfigRepository },
radioOperationLock = RadioOperationLock(),
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
liveUpdatePrefs = FakeLiveUpdatePrefs(),
meshPrefs = FakeMeshPrefs(),
localeUnitsProvider = FakeLocaleUnitsProvider(),
scope = scope.asServiceScope(),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.notificationId
import org.meshtastic.core.testing.FakeLiveUpdatePrefs
import org.meshtastic.core.testing.FakeLocaleUnitsProvider
import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DuplicatedPublicKey
Expand Down Expand Up @@ -381,6 +384,9 @@ class MeshNotificationManagerImplRoutingTest {
radioConfigRepository = lazy { error("Not used in this test") },
radioOperationLock = RadioOperationLock(),
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
liveUpdatePrefs = FakeLiveUpdatePrefs(),
meshPrefs = FakeMeshPrefs(),
localeUnitsProvider = FakeLocaleUnitsProvider(),
scope = scope.asServiceScope(),
)

Expand Down
Loading