diff --git a/conductor/archive/desktop_ux_enhancements_20260316/index.md b/conductor/archive/desktop_ux_enhancements_20260316/index.md new file mode 100644 index 000000000..cb8939351 --- /dev/null +++ b/conductor/archive/desktop_ux_enhancements_20260316/index.md @@ -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) \ No newline at end of file diff --git a/conductor/archive/desktop_ux_enhancements_20260316/metadata.json b/conductor/archive/desktop_ux_enhancements_20260316/metadata.json new file mode 100644 index 000000000..2adf241f1 --- /dev/null +++ b/conductor/archive/desktop_ux_enhancements_20260316/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "desktop_ux_enhancements_20260316", + "name": "Desktop UX Enhancements", + "status": "in-progress", + "priority": "medium", + "tags": ["desktop", "ux", "compose"] +} \ No newline at end of file diff --git a/conductor/archive/desktop_ux_enhancements_20260316/plan.md b/conductor/archive/desktop_ux_enhancements_20260316/plan.md new file mode 100644 index 000000000..a78fe5bdb --- /dev/null +++ b/conductor/archive/desktop_ux_enhancements_20260316/plan.md @@ -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 \ No newline at end of file diff --git a/conductor/archive/desktop_ux_enhancements_20260316/spec.md b/conductor/archive/desktop_ux_enhancements_20260316/spec.md new file mode 100644 index 000000000..546b4e5c8 --- /dev/null +++ b/conductor/archive/desktop_ux_enhancements_20260316/spec.md @@ -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). \ No newline at end of file diff --git a/conductor/archive/wire_up_notifs_20260316/index.md b/conductor/archive/wire_up_notifs_20260316/index.md new file mode 100644 index 000000000..10475a87b --- /dev/null +++ b/conductor/archive/wire_up_notifs_20260316/index.md @@ -0,0 +1,5 @@ +# Track wire_up_notifs_20260316 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) \ No newline at end of file diff --git a/conductor/archive/wire_up_notifs_20260316/metadata.json b/conductor/archive/wire_up_notifs_20260316/metadata.json new file mode 100644 index 000000000..e37b2b1ba --- /dev/null +++ b/conductor/archive/wire_up_notifs_20260316/metadata.json @@ -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" +} \ No newline at end of file diff --git a/conductor/archive/wire_up_notifs_20260316/plan.md b/conductor/archive/wire_up_notifs_20260316/plan.md new file mode 100644 index 000000000..f599f7d1d --- /dev/null +++ b/conductor/archive/wire_up_notifs_20260316/plan.md @@ -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) \ No newline at end of file diff --git a/conductor/archive/wire_up_notifs_20260316/spec.md b/conductor/archive/wire_up_notifs_20260316/spec.md new file mode 100644 index 000000000..0cce32a61 --- /dev/null +++ b/conductor/archive/wire_up_notifs_20260316/spec.md @@ -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. \ No newline at end of file diff --git a/conductor/product.md b/conductor/product.md index 1004f1f8c..53a1d4dc2 100644 --- a/conductor/product.md +++ b/conductor/product.md @@ -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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index 34bc23128..4d35a27df 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -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, 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) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index dcc0cc4a3..b1a33330d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -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, ) : 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() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index df1790709..6e029545d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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, 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, + ), ) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 363de37d5..dd554e6ea 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -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 } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index 25b609198..ec39c882d 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -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) } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 33475c2ff..0fc6462ed 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -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 = 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, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index b9eca56de..906055e4b 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -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 diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt new file mode 100644 index 000000000..c72c447bc --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt @@ -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 . + */ +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) +} diff --git a/core/prefs/src/androidUnitTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/androidUnitTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt new file mode 100644 index 000000000..604ef0f23 --- /dev/null +++ b/core/prefs/src/androidUnitTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -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 . + */ +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 + 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) + } +} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt new file mode 100644 index 000000000..ccefd94e1 --- /dev/null +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt @@ -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 . + */ +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, + dispatchers: CoroutineDispatchers, +) : NotificationPrefs { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + + override val messagesEnabled: StateFlow = + 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 = + 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 = + 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") + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index 82f7ff86b..8c66147d1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -84,6 +84,21 @@ interface UiPrefs { fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) } +/** Reactive interface for notification preferences. */ +interface NotificationPrefs { + val messagesEnabled: StateFlow + + fun setMessagesEnabled(enabled: Boolean) + + val nodeEventsEnabled: StateFlow + + fun setNodeEventsEnabled(enabled: Boolean) + + val lowBatteryEnabled: StateFlow + + fun setLowBatteryEnabled(enabled: Boolean) +} + /** Reactive interface for general map preferences. */ interface MapPrefs { val mapStyle: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt new file mode 100644 index 000000000..028eaa9ae --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt @@ -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 . + */ +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, + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt new file mode 100644 index 000000000..85afeea79 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt @@ -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 . + */ +package org.meshtastic.core.repository + +interface NotificationManager { + fun dispatch(notification: Notification) + + fun cancel(id: Int) + + fun cancelAll() +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt new file mode 100644 index 000000000..8792315dd --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt @@ -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 . + */ +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()!! + + 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() + } +} diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt new file mode 100644 index 000000000..62e90c356 --- /dev/null +++ b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt @@ -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 . + */ +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()) } + } +} diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/NotificationManagerTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/NotificationManagerTest.kt new file mode 100644 index 000000000..e5e464641 --- /dev/null +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/NotificationManagerTest.kt @@ -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 . + */ +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(relaxed = true) + val notification = Notification("Title", "Message") + + manager.dispatch(notification) + + verify { manager.dispatch(notification) } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 2341a3734..04abdf415 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -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. */ diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt new file mode 100644 index 000000000..5a871efd6 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -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 . + */ +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(extraBufferCapacity = 10) + val notifications: SharedFlow = _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 + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 1ea53339b..c1555c5db 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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() } + val desktopPrefs = remember { koinApp.koin.get() } + 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) } + } } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt new file mode 100644 index 000000000..9af34f28d --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt @@ -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 . + */ +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) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f) + val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f) + val windowX: StateFlow = dataStore.prefStateFlow(key = WINDOW_X, default = Float.NaN) + val windowY: StateFlow = 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 DataStore.prefStateFlow( + key: Preferences.Key, + default: T, + started: SharingStarted = SharingStarted.Lazily, + ): StateFlow = 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) + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 448d98155..edaea3c50 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -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 { NoopMeshServiceNotifications() } + single { + org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) + } single { NoopPlatformAnalytics() } single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index 9d10a1b60..c5f5a33f8 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -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" } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt new file mode 100644 index 000000000..39f8c0514 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -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 . + */ +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()) + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 927fd8740..1a08b3f50 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -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, 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 = { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt index 43d257f9d..833f377b0 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt @@ -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, diff --git a/desktop/src/main/resources/tray_icon_black.svg b/desktop/src/main/resources/tray_icon_black.svg new file mode 100644 index 000000000..bf1a8916e --- /dev/null +++ b/desktop/src/main/resources/tray_icon_black.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/desktop/src/main/resources/tray_icon_white.svg b/desktop/src/main/resources/tray_icon_white.svg new file mode 100644 index 000000000..89bf128f4 --- /dev/null +++ b/desktop/src/main/resources/tray_icon_white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index e7ebda5c6..87fd5a258 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -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()) } } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index b6ac28991..78fbd0629 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -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(false) } uiPrefs = mockk(relaxed = true) { every { showQuickChat } returns MutableStateFlow(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), ) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeErrorHandlingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeErrorHandlingTest.kt index efe4beec6..c9e0a3e9f 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeErrorHandlingTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeErrorHandlingTest.kt @@ -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") diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeIntegrationTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeIntegrationTest.kt index 0c84449c7..129fce8eb 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeIntegrationTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeIntegrationTest.kt @@ -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 diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 925681f2f..bced92050 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -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() diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt index cb6ef918b..cf953651f 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt @@ -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, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index eba0bb257..a6c8abfb9 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -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 = _meshLogRetentionDays.asStateFlow() diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt new file mode 100644 index 000000000..fb27e947e --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NotificationSection.kt @@ -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 . + */ +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) }, + ) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 1e94d311e..17105898c 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -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),