feat: Integrate notification management and preferences across platforms (#4819)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-16 20:17:34 -05:00
committed by GitHub
parent 0b2e89c46f
commit 8c964a15ca
45 changed files with 1304 additions and 61 deletions

View File

@@ -0,0 +1,8 @@
# Desktop UX Enhancements
This track focuses on integrating desktop-specific Compose Multiplatform APIs to improve the native feel and functionality of the desktop client.
## Track Files
- [Specification](./spec.md)
- [Plan](./plan.md)
- [Metadata](./metadata.json)

View File

@@ -0,0 +1,7 @@
{
"id": "desktop_ux_enhancements_20260316",
"name": "Desktop UX Enhancements",
"status": "in-progress",
"priority": "medium",
"tags": ["desktop", "ux", "compose"]
}

View File

@@ -0,0 +1,19 @@
# Implementation Plan: Desktop UX Enhancements
## Phase 1: Tray & Notifications (Current Focus)
- [x] Add `isAppVisible` state to `Main.kt`.
- [x] Introduce `rememberTrayState()` and the `Tray` composable.
- [x] Update `Window` `onCloseRequest` to toggle visibility instead of exiting the app.
- [x] Add a `DesktopNotificationService` interface and implementation using `TrayState`.
## Phase 2: Window State Persistence
- [x] Create `DesktopPreferencesDataSource` via DataStore.
- [x] Intercept window bounds changes and write to preferences.
- [x] Read preferences on startup to initialize `rememberWindowState(...)`.
## Phase 3: Menu Bar & Shortcuts
- [x] Integrate the `MenuBar` composable into the `Window`.
- [x] Implement global application shortcuts.
## Phase: Review Fixes
- [x] Task: Apply review suggestions 3bda1c007

View File

@@ -0,0 +1,10 @@
# Specification: Desktop UX Enhancements
## Goal
To implement native desktop behaviors like a system tray, notifications, a menu bar, and persistent window state for the Compose Multiplatform Desktop app.
## Requirements
1. **System Tray & Notifications**: The app should show a tray icon with a basic context menu ("Open", "Settings", "Quit"). It should support a "Minimize to Tray" flow rather than exiting immediately when closed. Notifications should be dispatchable via `TrayState` for key mesh events.
2. **Window State Persistence**: The app should remember its last window size, position, and maximized state across launches.
3. **Menu Bar**: A native MenuBar (File, Edit, View, Window, Help) should provide standard navigation and controls.
4. **Keyboard Shortcuts**: Common actions should be bound to standard native keyboard shortcuts (e.g. `Cmd/Ctrl+,` for Settings).

View File

@@ -0,0 +1,5 @@
# Track wire_up_notifs_20260316 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View File

@@ -0,0 +1,8 @@
{
"track_id": "wire_up_notifs_20260316",
"type": "feature",
"status": "new",
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z",
"description": "wire up notifs"
}

View File

@@ -0,0 +1,34 @@
# Implementation Plan: Wire Up Notifications
## Phase 1: Shared Abstraction (commonMain) [checkpoint: 930ce02]
- [x] Task: Define `NotificationManager` interface in `core:service/src/commonMain` 4f2107d
- [x] Create `Notification` data model (title, message, type)
- [x] Define `dispatch(notification: Notification)` method
- [x] Task: Create `NotificationPreferencesDataSource` using DataStore in `core:prefs` 346c2a4
- [x] Define boolean preferences for categories (e.g., Messages, Node Events)
- [x] Task: Conductor - User Manual Verification 'Phase 1: Shared Abstraction (commonMain)' (Protocol in workflow.md)
## Phase 2: Migrate Android Implementation (androidMain) [checkpoint: 1eb3cb0]
- [x] Task: Audit existing Android notifications 930ce02
- [x] Locate current implementation for local push notifications
- [x] Analyze triggers and UX (channels, icons, sounds)
- [x] Task: Implement `AndroidNotificationManager` 31c2a1e
- [x] Adapt existing Android notification code to the new `NotificationManager` interface
- [x] Inject `Context` and `NotificationPreferencesDataSource`
- [x] Respect user notification preferences
- [x] Task: Wire `AndroidNotificationManager` into Koin DI 31c2a1e
- [x] Task: Replace old Android notification calls with the new unified interface 81fd10b
- [x] Task: Conductor - User Manual Verification 'Phase 2: Migrate Android Implementation (androidMain)' (Protocol in workflow.md)
## Phase 3: Desktop Implementation (desktop) [checkpoint: 759914f]
- [x] Task: Implement `DesktopNotificationManager` 1eb3cb0
- [x] Inject `TrayState` and `NotificationPreferencesDataSource`
- [x] Delegate `dispatch()` to `TrayState.sendNotification()` respecting user preferences
- [x] Task: Wire `DesktopNotificationManager` into Koin DI 1eb3cb0
- [x] Task: Conductor - User Manual Verification 'Phase 3: Desktop Implementation (desktop)' (Protocol in workflow.md)
## Phase 4: UI Preferences Integration [checkpoint: 3af1e4c]
- [x] Task: Create UI for notification preferences 7ed59c6
- [x] Add toggles for categories in the Settings screen
- [x] Task: Conductor - User Manual Verification 'Phase 4: UI Preferences Integration' (Protocol in workflow.md)

View File

@@ -0,0 +1,17 @@
# Specification: Wire Up Notifications
## Goal
To implement a unified, cross-platform notification system that abstracts platform-specific implementations (Android local push, Desktop TrayState) into a common API for the Kotlin Multiplatform (KMP) core. This will enable consistent notification dispatching for key mesh events.
## Requirements
1. **Abstraction Layer:** Create a shared `NotificationManager` interface in `commonMain` to handle notification dispatching across all targets.
2. **Platform Implementations:**
- **Android:** Implement native local notifications following the existing Android app behavior and Material Design guidance.
- **Desktop:** Implement system notifications using the `TrayState` API.
3. **Trigger Events:** Replicate the existing Android notification triggers (e.g., new messages, connections) and adapt them to use the new shared abstraction.
4. **User Preferences:** Provide a unified UI for users to opt in or out of specific notification categories, respecting their choices globally.
5. **Foreground Handling & Behavior:** Defer to platform-specific UX guidelines and the established Android implementation for aspects like sound, vibration, and in-app display (e.g., suppressing system notifications if the conversation is active).
## Out of Scope
- Changes to the underlying networking or Bluetooth layers.
- Remote Push Notifications (FCM/APNs) this is strictly for local, mesh-driven events.

View File

@@ -14,6 +14,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
## Core Features
- Direct communication with Meshtastic hardware (via BLE, USB, TCP)
- Decentralized text messaging across the mesh network
- Unified cross-platform notifications for messages and node events
- Adaptive node and contact management
- Offline map rendering and device positioning
- Device configuration and firmware updates

View File

@@ -19,10 +19,14 @@ package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@@ -32,7 +36,7 @@ class FromRadioPacketHandlerImpl(
private val router: Lazy<MeshRouter>,
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
@@ -62,7 +66,13 @@ class FromRadioPacketHandlerImpl(
channel != null -> router.value.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
notificationManager.dispatch(
Notification(
title = getString(Res.string.client_notification),
message = clientNotification.message,
category = Notification.Category.Alert,
),
)
packetHandler.removeResponse(0, complete = false)
}
}

View File

@@ -37,8 +37,8 @@ import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -61,7 +61,7 @@ class MeshActionHandlerImpl(
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -346,7 +346,7 @@ class MeshActionHandlerImpl(
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
serviceNotifications.clearNotifications()
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}

View File

@@ -51,6 +51,8 @@ import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
@@ -62,6 +64,8 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.AdminMessage
@@ -96,6 +100,7 @@ class MeshDataHandlerImpl(
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
@@ -396,6 +401,7 @@ class MeshDataHandlerImpl(
rememberDataPacket(dataPacket, myNodeNum)
}
@Suppress("LongMethod")
private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
@@ -425,7 +431,18 @@ class MeshDataHandlerImpl(
) {
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
notificationManager.dispatch(
Notification(
title = getString(Res.string.low_battery_title, nextNode.user.short_name),
message =
getString(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
),
category = Notification.Category.Battery,
),
)
}
}
} else {
@@ -435,7 +452,7 @@ class MeshDataHandlerImpl(
batteryPercentCooldowns.remove(fromNum)
}
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
notificationManager.cancel(nextNode.num)
}
}
}
@@ -642,10 +659,13 @@ class MeshDataHandlerImpl(
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(Res.string.critical_alert),
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getString(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
@@ -682,12 +702,14 @@ class MeshDataHandlerImpl(
PortNum.WAYPOINT_APP.value -> {
val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
serviceNotifications.updateWaypointNotification(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.waypoint!!.id,
isSilent,
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}

View File

@@ -37,10 +37,14 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
@@ -56,7 +60,7 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -192,7 +196,13 @@ class NodeManagerImpl(
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
}
if (newNode && !shouldPreserve) {
serviceNotifications.showNewNodeSeenNotification(next)
notificationManager.dispatch(
Notification(
title = getString(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
}
next
}

View File

@@ -18,14 +18,16 @@ package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
@@ -39,19 +41,23 @@ class FromRadioPacketHandlerImplTest {
private val router: MeshRouter = mockk(relaxed = true)
private val mqttManager: MqttManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private lateinit var handler: FromRadioPacketHandlerImpl
@Before
fun setup() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { getString(any()) } returns "test string"
every { getString(any(), *anyVararg()) } returns "test string"
handler =
FromRadioPacketHandlerImpl(
serviceRepository,
lazy { router },
mqttManager,
packetHandler,
serviceNotifications,
notificationManager,
)
}
@@ -126,7 +132,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { serviceNotifications.showClientNotification(notification) }
verify { notificationManager.dispatch(any()) }
verify { packetHandler.removeResponse(0, complete = false) }
}
}

View File

@@ -38,6 +38,7 @@ import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
@@ -58,6 +59,7 @@ class MeshDataHandlerTest {
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val packetRepositoryLazy: Lazy<PacketRepository> = lazy { packetRepository }
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val dataMapper: MeshDataMapper = mockk(relaxed = true)
@@ -86,6 +88,7 @@ class MeshDataHandlerTest {
serviceRepository,
packetRepositoryLazy,
serviceBroadcasts,
notificationManager,
serviceNotifications,
analytics,
dataMapper,

View File

@@ -16,7 +16,9 @@
*/
package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@@ -24,9 +26,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
@@ -35,13 +38,17 @@ class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private lateinit var nodeManager: NodeManagerImpl
@Before
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, serviceNotifications)
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { getString(any()) } returns "test string"
every { getString(any(), *anyVararg()) } returns "test string"
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
}
@Test

View File

@@ -0,0 +1,30 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.NotificationPrefs
/** Use case for updating application-level notification preferences. */
@Single
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
}

View File

@@ -0,0 +1,85 @@
/*
* 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 io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.NotificationPrefs
class NotificationPrefsTest {
@get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
private lateinit var dataStore: DataStore<Preferences>
private lateinit var notificationPrefs: NotificationPrefs
private lateinit var dispatchers: CoroutineDispatchers
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Before
fun setup() {
dataStore =
PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers)
}
@Test
fun `messagesEnabled defaults to true`() = testScope.runTest { assertTrue(notificationPrefs.messagesEnabled.value) }
@Test
fun `nodeEventsEnabled defaults to true`() =
testScope.runTest { assertTrue(notificationPrefs.nodeEventsEnabled.value) }
@Test
fun `lowBatteryEnabled defaults to true`() =
testScope.runTest { assertTrue(notificationPrefs.lowBatteryEnabled.value) }
@Test
fun `setting messagesEnabled updates preference`() = testScope.runTest {
notificationPrefs.setMessagesEnabled(false)
assertFalse(notificationPrefs.messagesEnabled.value)
}
@Test
fun `setting nodeEventsEnabled updates preference`() = testScope.runTest {
notificationPrefs.setNodeEventsEnabled(false)
assertFalse(notificationPrefs.nodeEventsEnabled.value)
}
@Test
fun `setting lowBatteryEnabled updates preference`() = testScope.runTest {
notificationPrefs.setLowBatteryEnabled(false)
assertFalse(notificationPrefs.lowBatteryEnabled.value)
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
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.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.NotificationPrefs
@Single
class NotificationPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : NotificationPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val messagesEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_MESSAGES_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setMessagesEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_MESSAGES_ENABLED] = enabled } }
}
override val nodeEventsEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_NODE_EVENTS_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setNodeEventsEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_NODE_EVENTS_ENABLED] = enabled } }
}
override val lowBatteryEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_LOW_BATTERY_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setLowBatteryEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_LOW_BATTERY_ENABLED] = enabled } }
}
private companion object {
val KEY_MESSAGES_ENABLED = booleanPreferencesKey("notif_messages_enabled")
val KEY_NODE_EVENTS_ENABLED = booleanPreferencesKey("notif_node_events_enabled")
val KEY_LOW_BATTERY_ENABLED = booleanPreferencesKey("notif_low_battery_enabled")
}
}

View File

@@ -84,6 +84,21 @@ interface UiPrefs {
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
}
/** Reactive interface for notification preferences. */
interface NotificationPrefs {
val messagesEnabled: StateFlow<Boolean>
fun setMessagesEnabled(enabled: Boolean)
val nodeEventsEnabled: StateFlow<Boolean>
fun setNodeEventsEnabled(enabled: Boolean)
val lowBatteryEnabled: StateFlow<Boolean>
fun setLowBatteryEnabled(enabled: Boolean)
}
/** Reactive interface for general map preferences. */
interface MapPrefs {
val mapStyle: StateFlow<Int>

View File

@@ -0,0 +1,43 @@
/*
* 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
data class Notification(
val title: String,
val message: String,
val type: Type = Type.Info,
val category: Category = Category.Message,
val contactKey: String? = null,
val isSilent: Boolean = false,
val group: String? = null,
val id: Int? = null,
) {
enum class Type {
None,
Info,
Warning,
Error,
}
enum class Category {
Message,
NodeEvent,
Battery,
Alert,
Service,
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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
interface NotificationManager {
fun dispatch(notification: Notification)
fun cancel(id: Int)
fun cancelAll()
}

View File

@@ -0,0 +1,111 @@
/*
* 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.service
import android.app.NotificationChannel
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_messages_notifications
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
import org.meshtastic.core.resources.meshtastic_service_notifications
import android.app.NotificationManager as SystemNotificationManager
@Single
class AndroidNotificationManager(private val context: Context) : NotificationManager {
private val notificationManager = context.getSystemService<SystemNotificationManager>()!!
init {
initChannels()
}
private fun initChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels =
listOf(
createChannel(
Notification.Category.Message,
Res.string.meshtastic_messages_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.NodeEvent,
Res.string.meshtastic_new_nodes_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Battery,
Res.string.meshtastic_low_battery_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Alert,
Res.string.meshtastic_alerts_notifications,
SystemNotificationManager.IMPORTANCE_HIGH,
),
createChannel(
Notification.Category.Service,
Res.string.meshtastic_service_notifications,
SystemNotificationManager.IMPORTANCE_MIN,
),
)
notificationManager.createNotificationChannels(channels)
}
}
private fun createChannel(
category: Notification.Category,
nameRes: org.jetbrains.compose.resources.StringResource,
importance: Int,
): NotificationChannel = NotificationChannel(category.name, getString(nameRes), importance)
override fun dispatch(notification: Notification) {
val builder =
NotificationCompat.Builder(context, notification.category.name)
.setContentTitle(notification.title)
.setContentText(notification.message)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
.setSilent(notification.isSilent)
notification.group?.let { builder.setGroup(it) }
if (notification.type == Notification.Type.Error) {
builder.setPriority(NotificationCompat.PRIORITY_HIGH)
}
val id = notification.id ?: notification.hashCode()
notificationManager.notify(id, builder.build())
}
override fun cancel(id: Int) {
notificationManager.cancel(id)
}
override fun cancelAll() {
notificationManager.cancelAll()
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.service
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationPrefs
import android.app.NotificationManager as SystemNotificationManager
class AndroidNotificationManagerTest {
private lateinit var context: Context
private lateinit var notificationManager: SystemNotificationManager
private lateinit var prefs: NotificationPrefs
private lateinit var androidNotificationManager: AndroidNotificationManager
private val messagesEnabled = MutableStateFlow(true)
private val nodeEventsEnabled = MutableStateFlow(true)
private val lowBatteryEnabled = MutableStateFlow(true)
@Before
fun setup() {
context = mockk(relaxed = true)
notificationManager = mockk(relaxed = true)
prefs = mockk {
every { messagesEnabled } returns this@AndroidNotificationManagerTest.messagesEnabled
every { nodeEventsEnabled } returns this@AndroidNotificationManagerTest.nodeEventsEnabled
every { lowBatteryEnabled } returns this@AndroidNotificationManagerTest.lowBatteryEnabled
}
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns notificationManager
every { context.packageName } returns "org.meshtastic.test"
// Mocking initChannels to avoid getString calls during initialization for now if possible
// but it's called in init block.
androidNotificationManager = AndroidNotificationManager(context, prefs)
}
@Test
fun `dispatch notifies when enabled`() {
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify { notificationManager.notify(any(), any()) }
}
@Test
fun `dispatch does not notify when disabled`() {
messagesEnabled.value = false
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify(exactly = 0) { notificationManager.notify(any(), any()) }
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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.service
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
class NotificationManagerTest {
@Test
fun `dispatch calls implementation`() {
val manager = mockk<NotificationManager>(relaxed = true)
val notification = Notification("Title", "Message")
manager.dispatch(notification)
verify { manager.dispatch(notification) }
}
}

View File

@@ -46,8 +46,8 @@ import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
@@ -77,7 +77,7 @@ class UIViewModel(
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
) : ViewModel() {
@@ -107,7 +107,7 @@ class UIViewModel(
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
notificationManager.cancel(notification.toString().hashCode())
}
/** Emits events for mesh network send/receive activity. */

View File

@@ -0,0 +1,63 @@
/*
* 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.desktop
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.NotificationPrefs
import androidx.compose.ui.window.Notification as ComposeNotification
@Single
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
override fun dispatch(notification: Notification) {
val enabled =
when (notification.category) {
Notification.Category.Message -> prefs.messagesEnabled.value
Notification.Category.NodeEvent -> prefs.nodeEventsEnabled.value
Notification.Category.Battery -> prefs.lowBatteryEnabled.value
Notification.Category.Alert -> true
Notification.Category.Service -> true
}
if (!enabled) return
val composeType =
when (notification.type) {
Notification.Type.None -> ComposeNotification.Type.None
Notification.Type.Info -> ComposeNotification.Type.Info
Notification.Type.Warning -> ComposeNotification.Type.Warning
Notification.Type.Error -> ComposeNotification.Type.Error
}
_notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
}
override fun cancel(id: Int) {
// Desktop Tray notifications cannot be cancelled once sent via TrayState
}
override fun cancelAll() {
// Desktop Tray notifications cannot be cleared once sent via TrayState
}
}

View File

@@ -19,23 +19,43 @@ package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.first
import org.koin.core.context.startKoin
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.radio.DesktopMeshServiceController
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.desktop.ui.navSavedStateConfig
import java.util.Locale
/**
@@ -54,7 +74,8 @@ import java.util.Locale
*/
private val LocalAppLocale = staticCompositionLocalOf { "" }
fun main() = application {
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun main() = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
@@ -83,18 +104,133 @@ fun main() = application {
else -> isSystemInDarkTheme()
}
Window(
onCloseRequest = ::exitApplication,
title = "Meshtastic Desktop",
icon = painterResource("icon.png"),
state = rememberWindowState(width = 1024.dp, height = 768.dp),
) {
// Providing localePref via a staticCompositionLocalOf forces the entire subtree to
// recompose when the locale changes — CMP Resources' rememberResourceEnvironment then
// re-reads Locale.current and all stringResource() calls update. Unlike key(), this
// preserves remembered state (including the navigation backstack).
CompositionLocalProvider(LocalAppLocale provides localePref) {
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen() }
var isAppVisible by remember { mutableStateOf(true) }
var isWindowReady by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
val appIcon = painterResource("icon.png")
val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
}
LaunchedEffect(Unit) {
val initialWidth = desktopPrefs.windowWidth.first()
val initialHeight = desktopPrefs.windowHeight.first()
val initialX = desktopPrefs.windowX.first()
val initialY = desktopPrefs.windowY.first()
windowState.size = DpSize(initialWidth.dp, initialHeight.dp)
windowState.position =
if (!initialX.isNaN() && !initialY.isNaN()) {
WindowPosition(initialX.dp, initialY.dp)
} else {
WindowPosition(Alignment.Center)
}
isWindowReady = true
snapshotFlow {
val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN
val y = if (windowState.position.isSpecified) windowState.position.y.value else Float.NaN
listOf(windowState.size.width.value, windowState.size.height.value, x, y)
}
.collect { bounds ->
desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3])
}
}
Tray(
state = trayState,
icon = appIcon,
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item(
"Test Notification",
onClick = {
trayState.sendNotification(
Notification(
"Meshtastic",
"This is a test notification from the System Tray",
Notification.Type.Info,
),
)
},
)
Item("Quit", onClick = ::exitApplication)
},
)
if (isWindowReady && isAppVisible) {
Window(
onCloseRequest = { isAppVisible = false },
title = "Meshtastic Desktop",
icon = appIcon,
state = windowState,
) {
val backStack =
rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
MenuBar {
Menu("File") {
Item("Settings", shortcut = KeyShortcut(Key.Comma, meta = true)) {
if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) {
backStack.add(TopLevelDestination.Settings.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
}
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, meta = true)) { exitApplication() }
}
Menu("View") {
Item("Toggle Theme", shortcut = KeyShortcut(Key.T, meta = true, shift = true)) {
val newTheme = if (isDarkTheme) 1 else 2 // 1 = Light, 2 = Dark
uiPrefs.setTheme(newTheme)
}
}
Menu("Navigate") {
Item("Conversations", shortcut = KeyShortcut(Key.One, meta = true)) {
backStack.add(TopLevelDestination.Conversations.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
Item("Nodes", shortcut = KeyShortcut(Key.Two, meta = true)) {
backStack.add(TopLevelDestination.Nodes.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
Item("Map", shortcut = KeyShortcut(Key.Three, meta = true)) {
backStack.add(TopLevelDestination.Map.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
Item("Connections", shortcut = KeyShortcut(Key.Four, meta = true)) {
backStack.add(TopLevelDestination.Connections.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
}
Menu("Help") { Item("About") { backStack.add(SettingsRoutes.About) } }
}
// Providing localePref via a staticCompositionLocalOf forces the entire subtree to
// recompose when the locale changes — CMP Resources' rememberResourceEnvironment then
// re-reads Locale.current and all stringResource() calls update. Unlike key(), this
// preserves remembered state (including the navigation backstack).
CompositionLocalProvider(LocalAppLocale provides localePref) {
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) }
}
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025-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.desktop.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.Named
import org.koin.core.annotation.Single
const val KEY_WINDOW_WIDTH = "window_width"
const val KEY_WINDOW_HEIGHT = "window_height"
const val KEY_WINDOW_X = "window_x"
const val KEY_WINDOW_Y = "window_y"
@Single
class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val windowWidth: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f)
val windowHeight: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f)
val windowX: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_X, default = Float.NaN)
val windowY: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_Y, default = Float.NaN)
fun setWindowBounds(width: Float, height: Float, x: Float, y: Float) {
scope.launch {
dataStore.edit { prefs ->
prefs[WINDOW_WIDTH] = width
prefs[WINDOW_HEIGHT] = height
prefs[WINDOW_X] = x
prefs[WINDOW_Y] = y
}
}
}
private fun <T : Any> DataStore<Preferences>.prefStateFlow(
key: Preferences.Key<T>,
default: T,
started: SharingStarted = SharingStarted.Lazily,
): StateFlow<T> = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default)
companion object {
val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH)
val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT)
val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X)
val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y)
}
}

View File

@@ -49,7 +49,6 @@ import org.meshtastic.desktop.stub.NoopLocationRepository
import org.meshtastic.desktop.stub.NoopMQTTRepository
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshServiceNotifications
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
@@ -134,7 +133,9 @@ private fun desktopPlatformStubsModule() = module {
locationManager = get(),
)
}
single<MeshServiceNotifications> { NoopMeshServiceNotifications() }
single<MeshServiceNotifications> {
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
}
single<PlatformAnalytics> { NoopPlatformAnalytics() }
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }

View File

@@ -155,9 +155,9 @@ fun desktopPlatformModule() = module {
override val isDebug: Boolean = true
override val applicationId: String = "org.meshtastic.desktop"
override val versionCode: Int = 1
override val versionName: String = "0.1.0-desktop"
override val absoluteMinFwVersion: String = "2.0.0"
override val minFwVersion: String = "2.5.0"
override val versionName: String = "2.7.14"
override val absoluteMinFwVersion: String = "2.3.15"
override val minFwVersion: String = "2.5.14"
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.desktop.notification
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@Single
@Suppress("TooManyFunctions")
class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications {
override fun clearNotifications() {
notificationManager.cancelAll()
}
override fun initChannels() {
// no-op for desktop
}
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Any {
// We don't have a foreground service on desktop
return Unit
}
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {
notificationManager.dispatch(
Notification(
title = name,
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
id = contactKey.hashCode(),
),
)
}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {
notificationManager.dispatch(
Notification(
title = name,
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {
notificationManager.dispatch(
Notification(
title = name,
message = emoji,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}
@Suppress("ktlint:standard:max-line-length")
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
notificationManager.dispatch(
Notification(
title = name,
message = alert,
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
}
override fun showNewNodeSeenNotification(node: Node) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.new_node_seen, node.user.short_name),
message = node.user.long_name,
category = Notification.Category.NodeEvent,
),
)
}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.low_battery_title, node.user.short_name),
message = getString(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
category = Notification.Category.Battery,
id = node.num,
),
)
}
override fun showClientNotification(clientNotification: ClientNotification) {
notificationManager.dispatch(
Notification(
title = "Meshtastic",
message = clientNotification.message,
category = Notification.Category.Alert,
id = clientNotification.toString().hashCode(),
),
)
}
override fun cancelMessageNotification(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
}
override fun cancelLowBatteryNotification(node: Node) {
notificationManager.cancel(node.num)
}
override fun clearClientNotification(notification: ClientNotification) {
notificationManager.cancel(notification.toString().hashCode())
}
}

View File

@@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.modules.SerializersModule
@@ -55,7 +55,7 @@ import org.meshtastic.desktop.navigation.desktopNavGraph
* Polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used in the
* desktop navigation graph.
*/
private val navSavedStateConfig = SavedStateConfiguration {
internal val navSavedStateConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
// Nodes
@@ -142,8 +142,7 @@ private val navSavedStateConfig = SavedStateConfiguration {
* app, proving the shared backstack architecture works across targets.
*/
@Composable
fun DesktopMainScreen(radioService: RadioInterfaceService = koinInject()) {
val backStack = rememberNavBackStack(navSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, radioService: RadioInterfaceService = koinInject()) {
val currentKey = backStack.lastOrNull()
val selected = TopLevelDestination.fromNavKey(currentKey)
@@ -159,8 +158,10 @@ fun DesktopMainScreen(radioService: RadioInterfaceService = koinInject()) {
selected = destination == selected,
onClick = {
if (destination != selected) {
backStack.clear()
backStack.add(destination.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
},
icon = {

View File

@@ -74,6 +74,7 @@ import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
@@ -202,6 +203,15 @@ fun DesktopSettingsScreen(
)
}
NotificationSection(
messagesEnabled = settingsViewModel.messagesEnabled.collectAsStateWithLifecycle().value,
onToggleMessages = { settingsViewModel.setMessagesEnabled(it) },
nodeEventsEnabled = settingsViewModel.nodeEventsEnabled.collectAsStateWithLifecycle().value,
onToggleNodeEvents = { settingsViewModel.setNodeEventsEnabled(it) },
lowBatteryEnabled = settingsViewModel.lowBatteryEnabled.collectAsStateWithLifecycle().value,
onToggleLowBattery = { settingsViewModel.setLowBatteryEnabled(it) },
)
DesktopAppInfoSection(
appVersionName = settingsViewModel.appVersionName,
excludedModulesUnlocked = excludedModulesUnlocked,

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -42,8 +42,8 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -64,7 +64,7 @@ class MessageViewModel(
private val uiPrefs: UiPrefs,
private val customEmojiPrefs: CustomEmojiPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
private val sendMessageUseCase: SendMessageUseCase,
) : ViewModel() {
private val _title = MutableStateFlow("")
@@ -235,6 +235,6 @@ class MessageViewModel(
packetRepository.clearUnreadCount(contact, lastReadTimestamp)
packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp)
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
if (unreadCount == 0) notificationManager.cancel(contact.hashCode())
}
}

View File

@@ -26,7 +26,6 @@ import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
@@ -60,7 +59,6 @@ class MessageViewModelTest {
private lateinit var customEmojiPrefs: CustomEmojiPrefs
private lateinit var homoglyphPrefs: HomoglyphPrefs
private lateinit var uiPrefs: UiPrefs
private lateinit var meshServiceNotifications: MeshServiceNotifications
private fun setUp() {
// Create saved state with test contact ID
@@ -86,7 +84,6 @@ class MessageViewModelTest {
homoglyphPrefs =
mockk(relaxed = true) { every { homoglyphEncodingEnabled } returns MutableStateFlow<Boolean>(false) }
uiPrefs = mockk(relaxed = true) { every { showQuickChat } returns MutableStateFlow<Boolean>(false) }
meshServiceNotifications = mockk(relaxed = true)
// Create ViewModel with mocked dependencies
viewModel =
@@ -101,7 +98,7 @@ class MessageViewModelTest {
customEmojiPrefs = customEmojiPrefs,
homoglyphEncodingPrefs = homoglyphPrefs,
uiPrefs = uiPrefs,
meshServiceNotifications = meshServiceNotifications,
notificationManager = mockk(relaxed = true),
)
}

View File

@@ -16,7 +16,10 @@
*/
package org.meshtastic.feature.node.list
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
@@ -37,10 +40,16 @@ class NodeErrorHandlingTest {
@BeforeTest
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(kotlinx.coroutines.Dispatchers.Unconfined)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
}
@kotlin.test.AfterTest
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun testGetNonexistentNode() = runTest {
val node = nodeRepository.getNode("!nonexistent")

View File

@@ -16,7 +16,10 @@
*/
package org.meshtastic.feature.node.list
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
@@ -37,10 +40,16 @@ class NodeIntegrationTest {
@BeforeTest
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(kotlinx.coroutines.Dispatchers.Unconfined)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
}
@kotlin.test.AfterTest
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun testPopulatingMeshWithMultipleNodes() = runTest {
// Create diverse node set

View File

@@ -19,8 +19,11 @@ package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
@@ -51,6 +54,7 @@ class NodeListViewModelTest {
@BeforeTest
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(kotlinx.coroutines.Dispatchers.Unconfined)
// Use real fakes
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
@@ -82,6 +86,11 @@ class NodeListViewModelTest {
)
}
@kotlin.test.AfterTest
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun testInitialization() = runTest {
setUp()

View File

@@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -41,6 +42,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_notifications
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.intro_show
@@ -74,6 +76,18 @@ fun AppInfoSection(
onShowAppIntro()
}
ListItem(
text = stringResource(Res.string.app_notifications),
leadingIcon = Icons.Rounded.Notifications,
trailingIcon = null,
) {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
settingsLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,

View File

@@ -38,6 +38,7 @@ import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.MyNodeInfo
@@ -46,6 +47,7 @@ import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@@ -61,12 +63,14 @@ class SettingsViewModel(
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
private val meshLogPrefs: MeshLogPrefs,
private val notificationPrefs: NotificationPrefs,
private val setThemeUseCase: SetThemeUseCase,
private val setLocaleUseCase: SetLocaleUseCase,
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
private val setProvideLocationUseCase: SetProvideLocationUseCase,
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
private val setNotificationSettingsUseCase: SetNotificationSettingsUseCase,
private val meshLocationUseCase: MeshLocationUseCase,
private val exportDataUseCase: ExportDataUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase,
@@ -120,6 +124,17 @@ class SettingsViewModel(
setDatabaseCacheLimitUseCase(limit)
}
// Notifications
val messagesEnabled = notificationPrefs.messagesEnabled
val nodeEventsEnabled = notificationPrefs.nodeEventsEnabled
val lowBatteryEnabled = notificationPrefs.lowBatteryEnabled
fun setMessagesEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setLowBatteryEnabled(enabled)
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value)
val meshLogRetentionDays: StateFlow<Int> = _meshLogRetentionDays.asStateFlow()

View File

@@ -0,0 +1,64 @@
/*
* 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.feature.settings.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BatteryAlert
import androidx.compose.material.icons.rounded.Message
import androidx.compose.material.icons.rounded.PersonAdd
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_messages_notifications
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
import org.meshtastic.core.ui.component.SwitchListItem
/**
* Notification settings section with in-app toggles. Primarily used on platforms without system notification channels.
*/
@Composable
fun NotificationSection(
messagesEnabled: Boolean,
onToggleMessages: (Boolean) -> Unit,
nodeEventsEnabled: Boolean,
onToggleNodeEvents: (Boolean) -> Unit,
lowBatteryEnabled: Boolean,
onToggleLowBattery: (Boolean) -> Unit,
) {
ExpressiveSection(title = stringResource(Res.string.app_notifications)) {
SwitchListItem(
text = stringResource(Res.string.meshtastic_messages_notifications),
leadingIcon = Icons.Rounded.Message,
checked = messagesEnabled,
onClick = { onToggleMessages(!messagesEnabled) },
)
SwitchListItem(
text = stringResource(Res.string.meshtastic_new_nodes_notifications),
leadingIcon = Icons.Rounded.PersonAdd,
checked = nodeEventsEnabled,
onClick = { onToggleNodeEvents(!nodeEventsEnabled) },
)
SwitchListItem(
text = stringResource(Res.string.meshtastic_low_battery_notifications),
leadingIcon = Icons.Rounded.BatteryAlert,
checked = lowBatteryEnabled,
onClick = { onToggleLowBattery(!lowBatteryEnabled) },
)
}
}

View File

@@ -71,12 +71,14 @@ class SettingsViewModelTest {
buildConfigProvider = buildConfigProvider,
databaseManager = databaseManager,
meshLogPrefs = meshLogPrefs,
notificationPrefs = mockk(relaxed = true),
setThemeUseCase = mockk(relaxed = true),
setLocaleUseCase = mockk(relaxed = true),
setAppIntroCompletedUseCase = mockk(relaxed = true),
setProvideLocationUseCase = mockk(relaxed = true),
setDatabaseCacheLimitUseCase = mockk(relaxed = true),
setMeshLogSettingsUseCase = mockk(relaxed = true),
setNotificationSettingsUseCase = mockk(relaxed = true),
meshLocationUseCase = mockk(relaxed = true),
exportDataUseCase = mockk(relaxed = true),
isOtaCapableUseCase = mockk(relaxed = true),