Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c3548a6
fix(conductor): Apply review suggestions for track 'desktop_ux_enhanc…
jamesarich Mar 16, 2026
1346889
conductor(plan): Mark task 'Apply review suggestions' as complete
jamesarich Mar 16, 2026
23c4454
chore(conductor): Archive track 'Desktop UX Enhancements'
jamesarich Mar 16, 2026
1b6a986
chore(conductor): Add new track 'wire up notifs'
jamesarich Mar 16, 2026
ea52248
feat(service): Define NotificationManager interface and Notification …
jamesarich Mar 16, 2026
3d50771
conductor(plan): Mark task 'Define NotificationManager interface' as …
jamesarich Mar 16, 2026
2f5f370
feat(prefs): Create NotificationPrefs and NotificationPrefsImpl
jamesarich Mar 16, 2026
4268e0e
conductor(plan): Mark task 'Create NotificationPreferencesDataSource'…
jamesarich Mar 16, 2026
1ffd5d2
conductor(plan): Mark phase 'Phase 1: Shared Abstraction (commonMain)…
jamesarich Mar 16, 2026
f3b6612
feat(service): Implement AndroidNotificationManager
jamesarich Mar 16, 2026
0ad54b0
conductor(plan): Mark task 'Implement AndroidNotificationManager' as …
jamesarich Mar 16, 2026
c73abab
conductor(plan): Mark task 'Wire AndroidNotificationManager' as complete
jamesarich Mar 16, 2026
4f2ef0f
conductor(plan): Mark task 'Replace old Android notification calls' a…
jamesarich Mar 16, 2026
7411347
refactor(service): Move NotificationManager to core:repository and up…
jamesarich Mar 16, 2026
7f0af0d
feat(service): Replace MeshServiceNotifications calls with Notificati…
jamesarich Mar 16, 2026
9a6aecd
conductor(plan): Update commit SHA for task 'Replace old Android noti…
jamesarich Mar 16, 2026
e1153e5
test(data): Fix manager tests by mocking NotificationManager and getS…
jamesarich Mar 16, 2026
3bf4195
conductor(plan): Mark phase 'Phase 2: Migrate Android Implementation …
jamesarich Mar 16, 2026
cd89ac5
feat(desktop): Implement DesktopNotificationManager and DesktopMeshSe…
jamesarich Mar 16, 2026
a648220
conductor(plan): Mark task 'Wire DesktopNotificationManager' as complete
jamesarich Mar 16, 2026
e247430
fix(desktop): Fix non-null argument for getString in DesktopMeshServi…
jamesarich Mar 16, 2026
c3c0e2a
conductor(plan): Mark phase 'Phase 3: Desktop Implementation (desktop…
jamesarich Mar 16, 2026
b01d695
feat(settings): Integrate notification preferences into Settings screen
jamesarich Mar 16, 2026
632cc43
conductor(plan): Mark task 'Create UI for notification preferences' a…
jamesarich Mar 16, 2026
9f77a08
feat(settings): Make notification settings platform-aware (system set…
jamesarich Mar 16, 2026
a101937
feat(settings): Refine notification settings UI (Android uses system …
jamesarich Mar 16, 2026
ead1056
conductor(plan): Mark phase 'Phase 4: UI Preferences Integration' as …
jamesarich Mar 16, 2026
c21c25b
docs(conductor): Synchronize docs for track 'wire up notifs'
jamesarich Mar 16, 2026
9aef3be
chore(conductor): Archive track 'wire up notifs'
jamesarich Mar 16, 2026
7401009
refactor: unify notification settings and update desktop versioning
jamesarich Mar 16, 2026
9588229
spotless
jamesarich Mar 16, 2026
34bdb4b
spotless
jamesarich Mar 16, 2026
44d7abd
test(node): fix MissingMainCoroutineDispatcher in Node list unit tests
jamesarich Mar 17, 2026
8dc370e
test: Fix MessageViewModelTest compilation error
jamesarich Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions conductor/archive/desktop_ux_enhancements_20260316/index.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "desktop_ux_enhancements_20260316",
"name": "Desktop UX Enhancements",
"status": "in-progress",
"priority": "medium",
"tags": ["desktop", "ux", "compose"]
}
19 changes: 19 additions & 0 deletions conductor/archive/desktop_ux_enhancements_20260316/plan.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions conductor/archive/desktop_ux_enhancements_20260316/spec.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions conductor/archive/wire_up_notifs_20260316/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Track wire_up_notifs_20260316 Context

- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
8 changes: 8 additions & 0 deletions conductor/archive/wire_up_notifs_20260316/metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
34 changes: 34 additions & 0 deletions conductor/archive/wire_up_notifs_20260316/plan.md
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions conductor/archive/wire_up_notifs_20260316/spec.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions conductor/product.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -346,7 +346,7 @@ class MeshActionHandlerImpl(
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
serviceNotifications.clearNotifications()
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand All @@ -435,7 +452,7 @@ class MeshDataHandlerImpl(
batteryPercentCooldowns.remove(fromNum)
}
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
notificationManager.cancel(nextNode.num)
}
}
}
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}

Expand Down Expand Up @@ -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) }
}
}
Loading
Loading