From 41c82abc9ec56ffd6578e5fca7852b93b8b76d29 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 09:07:41 -0500
Subject: [PATCH 001/244] chore(deps): update kotest to v6.1.8 (#4902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7ce5d08fc..769890213 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,7 +26,7 @@ ktlint = "1.7.1"
ktfmt = "0.62"
kover = "0.9.7"
mokkery = "3.3.0"
-kotest = "6.1.7"
+kotest = "6.1.8"
testRetry = "1.6.4"
turbine = "1.2.1"
From b45bc9be907f4a9f3c9674cd8d33dd8ea1aa67e9 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:49:49 -0500
Subject: [PATCH 002/244] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4905)
---
.../src/commonMain/composeResources/values-bg/strings.xml | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index f23d53ce8..f93956a3d 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -937,6 +937,7 @@
%1$s
Статистика на Meshtastic
Опресняване
+ Актуализирано
Добавяне на мрежов слой
Опресняване на слоя
From cd328b236dbba29b5670cc304e445206e50e6fd4 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:21:40 -0500
Subject: [PATCH 003/244] chore(deps): update kotest to v6.1.9 (#4908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 769890213..d5581f056 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,7 +26,7 @@ ktlint = "1.7.1"
ktfmt = "0.62"
kover = "0.9.7"
mokkery = "3.3.0"
-kotest = "6.1.8"
+kotest = "6.1.9"
testRetry = "1.6.4"
turbine = "1.2.1"
From 3a9f611fc027590c14487128938affb3c7afbd28 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:21:46 -0500
Subject: [PATCH 004/244] chore(deps): update wire to v6.1.0 (#4906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d5581f056..1e6a33977 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -61,7 +61,7 @@ markdownRenderer = "0.39.2"
okio = "3.17.0"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
-wire = "6.0.0"
+wire = "6.1.0"
vico = "3.0.3"
dependency-guard = "0.5.0"
kable = "0.42.0"
From 0c3ab92908fcb42f15ee7ec9866d3bd9fac7ccc7 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:21:59 -0500
Subject: [PATCH 005/244] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4907)
---
app/src/main/assets/firmware_releases.json | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index cea32950b..9121f7bb5 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -188,6 +188,18 @@
]
},
"pullRequests": [
+ {
+ "id": "9999",
+ "title": "Use UDP as roof node <---> indoor nodes backchannel",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9999",
+ "zip_url": "https://discord.com/invite/meshtastic"
+ },
+ {
+ "id": "9998",
+ "title": "fix ExternalNotificationsModule Buzzer is all or nothing",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9998",
+ "zip_url": "https://discord.com/invite/meshtastic"
+ },
{
"id": "9955",
"title": "Add Env for Seeed XIAO ESP32-C6 + Wio-SX1262",
From 9b8ac6a4609c2174f06b50b0b0461c676120aa9b Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:22:37 -0500
Subject: [PATCH 006/244] build(desktop): enable ProGuard minification and
tree-shaking (#4904)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
desktop/README.md | 19 +++-
desktop/build.gradle.kts | 7 +-
desktop/proguard-rules.pro | 219 +++++++++++++++++++++++++++++++++++--
3 files changed, 231 insertions(+), 14 deletions(-)
diff --git a/desktop/README.md b/desktop/README.md
index 5e177a548..ea17d0eb7 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -16,10 +16,27 @@ A Compose Desktop application target — the first full non-Android target for t
# Run tests
./gradlew :desktop:test
-# Package native distribution (DMG/MSI/DEB)
+# Package native distribution (DMG/MSI/DEB) — debug (no ProGuard)
./gradlew :desktop:packageDistributionForCurrentOS
+
+# Package native distribution (DMG/MSI/DEB) — release (ProGuard minified)
+./gradlew :desktop:packageReleaseDistributionForCurrentOS
```
+## ProGuard / Minification
+
+Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source.
+
+**Configuration:**
+- `build.gradle.kts` — `buildTypes.release.proguard` block enables ProGuard with `optimize.set(true)` and `obfuscate.set(false)`.
+- `proguard-rules.pro` — Comprehensive keep-rules for all reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources).
+
+**Troubleshooting ProGuard issues:**
+- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro`.
+- To debug which classes ProGuard removes, temporarily add `-printusage proguard-usage.txt` to the rules file and inspect the output in `desktop/proguard-usage.txt`.
+- To see the full mapping of optimizations applied, add `-printseeds proguard-seeds.txt`.
+- Run `./gradlew :desktop:runRelease` for a quick smoke-test of the minified app before packaging.
+
## Architecture
The module depends on the JVM variants of KMP modules:
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index fe4e86c0c..1ffb0f96a 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -45,10 +45,9 @@ compose.desktop {
mainClass = "org.meshtastic.desktop.MainKt"
buildTypes.release.proguard {
- // Note: Enabling ProGuard will reduce final distribution size significantly,
- // but will require thorough testing of serialization, reflection (Koin), and JNI (SQLite).
- // Recommend enabling when ready: isEnabled.set(true)
- isEnabled.set(false)
+ isEnabled.set(true)
+ obfuscate.set(false) // Open-source project — obfuscation adds no value
+ optimize.set(true)
configurationFiles.from(project.file("proguard-rules.pro"))
}
diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro
index 10a50ac4e..a73c347d1 100644
--- a/desktop/proguard-rules.pro
+++ b/desktop/proguard-rules.pro
@@ -1,14 +1,215 @@
--dontwarn android.os.Parcel**
--dontwarn android.os.Parcelable**
--dontwarn com.squareup.wire.AndroidMessage**
--dontwarn io.ktor.**
+# ============================================================================
+# Meshtastic Desktop — ProGuard rules for release minification
+# ============================================================================
+# Open-source project: we rely on tree-shaking (unused code removal) for size
+# reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)).
+#
+# Key libraries requiring keep-rules (reflection, JNI, code generation):
+# Koin (DI via reflection), kotlinx-serialization (generated serializers),
+# Wire protobuf (ADAPTER reflection), Room KMP (generated DB + converters),
+# Ktor (Java engine + ServiceLoader), Kable BLE, Coil, Compose Multiplatform
+# resources, SQLite bundled (JNI), AboutLibraries.
+# ============================================================================
-# Room KMP: preserve generated database constructor (required for R8/ProGuard)
--keep class * extends androidx.room.RoomDatabase { (); }
+# ---- General ----------------------------------------------------------------
-# Suppress ProGuard notes about duplicate resource files (common in Compose Desktop)
+# Preserve line numbers for meaningful stack traces
+-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions
+
+# Suppress notes about duplicate resource files (common in fat JARs)
-dontnote **
-# Suppress specific reflection warnings that are safe to ignore
+# Do not parse/rewrite Kotlin metadata during shrinking/optimization.
+# ProGuard's KotlinShrinker cannot handle the metadata produced by Compose
+# Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException.
+# Since we disable obfuscation (class names remain stable), metadata references
+# stay valid and do not need rewriting. The annotations themselves are preserved
+# by -keepattributes *Annotation*.
+-dontprocesskotlinmetadata
+
+# ---- Entry point ------------------------------------------------------------
+
+-keep class org.meshtastic.desktop.MainKt { *; }
+
+# ---- Kotlin / Coroutines ---------------------------------------------------
+
+# Keep Kotlin metadata for reflection-dependent libraries
+-keep class kotlin.Metadata { *; }
+-keep class kotlin.reflect.** { *; }
+
+# Coroutines internals
+-dontwarn kotlinx.coroutines.**
+-keep class kotlinx.coroutines.** { *; }
+-keep class kotlin.coroutines.Continuation { *; }
+
+# ---- Koin DI (reflection-based injection) -----------------------------------
+
+# Koin core — uses reflection to instantiate definitions
+-keep class org.koin.** { *; }
+-dontwarn org.koin.**
+
+# Keep all Koin-annotated @Module / @ComponentScan classes and their generated
+# counterparts so Koin K2 plugin output survives tree-shaking.
+-keep @org.koin.core.annotation.Module class * { *; }
+-keep @org.koin.core.annotation.ComponentScan class * { *; }
+-keep @org.koin.core.annotation.Single class * { *; }
+-keep @org.koin.core.annotation.Factory class * { *; }
+
+# Generated Koin module extensions (K2 plugin output)
+-keep class org.meshtastic.**.di.** { *; }
+
+# ---- kotlinx-serialization --------------------------------------------------
+
+# The serialization plugin generates companion $serializer classes and
+# serializer() factory methods that are invoked reflectively.
+-keepattributes RuntimeVisibleAnnotations
+-keep class kotlinx.serialization.** { *; }
+-dontwarn kotlinx.serialization.**
+
+# Keep @Serializable classes and their generated serializers
+-keepclassmembers @kotlinx.serialization.Serializable class ** {
+ # Companion object that holds the serializer() factory
+ static ** Companion;
+ kotlinx.serialization.KSerializer serializer(...);
+}
+-keepclassmembers class **.$serializer { *; }
+-keep class **.$serializer { *; }
+-keepclasseswithmembers class ** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# ---- Wire protobuf ----------------------------------------------------------
+
+# Wire generates ADAPTER companion objects accessed via reflection
+-keep class com.squareup.wire.** { *; }
+-dontwarn com.squareup.wire.**
+
+# All generated proto message classes
+-keep class org.meshtastic.proto.** { *; }
+-keep class meshtastic.** { *; }
+
+# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs)
+-dontwarn android.os.Parcel**
+-dontwarn android.os.Parcelable**
+
+# ---- Room KMP ---------------------------------------------------------------
+
+# Preserve generated database constructors (required for Room's reflective init)
+-keep class * extends androidx.room3.RoomDatabase { (); }
+-keep class * implements androidx.room3.RoomDatabaseConstructor { *; }
+
+# Keep the expect/actual MeshtasticDatabaseConstructor
+-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; }
+-keep class org.meshtastic.core.database.MeshtasticDatabase { *; }
+
+# Room DAOs — Room generates implementations at compile time; keep interfaces
+-keep class org.meshtastic.core.database.dao.** { *; }
+
+# Room Entities — accessed via reflection for column mapping
+-keep class org.meshtastic.core.database.entity.** { *; }
+
+# Room TypeConverters — invoked reflectively
+-keep class org.meshtastic.core.database.Converters { *; }
+
+# Room generated _Impl classes
+-keep class **_Impl { *; }
+
+# ---- SQLite bundled (JNI) ---------------------------------------------------
+
+-keep class androidx.sqlite.** { *; }
+-dontwarn androidx.sqlite.**
+
+# ---- Ktor (Java engine + ServiceLoader + content negotiation) ---------------
+
+# Ktor uses ServiceLoader and reflection for engine/plugin discovery
+-keep class io.ktor.** { *; }
+-dontwarn io.ktor.**
+
+# Keep ServiceLoader metadata files
+-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
+
+# Java HTTP client engine
+-keep class io.ktor.client.engine.java.** { *; }
+
+# ---- Coil (image loading) ---------------------------------------------------
+
+-keep class coil3.** { *; }
+-dontwarn coil3.**
+
+# ---- Kable BLE --------------------------------------------------------------
+
+-keep class com.juul.kable.** { *; }
+-dontwarn com.juul.kable.**
+
+# ---- Compose Multiplatform resources ----------------------------------------
+
+# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.)
+-keep class org.jetbrains.compose.resources.** { *; }
+-keep class org.meshtastic.core.resources.** { *; }
+
+# ---- AboutLibraries ---------------------------------------------------------
+
+-keep class com.mikepenz.aboutlibraries.** { *; }
+-dontwarn com.mikepenz.aboutlibraries.**
+
+# ---- Multiplatform Markdown Renderer ----------------------------------------
+
+-keep class com.mikepenz.markdown.** { *; }
+-dontwarn com.mikepenz.markdown.**
+
+# ---- QR Code Kotlin ---------------------------------------------------------
+
+-keep class io.github.g0dkar.qrcode.** { *; }
+-dontwarn io.github.g0dkar.qrcode.**
+-keep class qrcode.** { *; }
+-dontwarn qrcode.**
+
+# ---- Kermit logging ----------------------------------------------------------
+
+-keep class co.touchlab.kermit.** { *; }
+-dontwarn co.touchlab.kermit.**
+
+# ---- Okio -------------------------------------------------------------------
+
+-dontwarn okio.**
+-keep class okio.** { *; }
+
+# ---- DataStore --------------------------------------------------------------
+
+-keep class androidx.datastore.** { *; }
+-dontwarn androidx.datastore.**
+
+# ---- Paging -----------------------------------------------------------------
+
+-keep class androidx.paging.** { *; }
+-dontwarn androidx.paging.**
+
+# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) -------------------
+
+-keep class androidx.lifecycle.** { *; }
+-keep class androidx.navigation3.** { *; }
+-dontwarn androidx.lifecycle.**
+-dontwarn androidx.navigation3.**
+
+# ---- Meshtastic application code --------------------------------------------
+
+# Keep all desktop module classes (thin host shell — not worth tree-shaking)
+-keep class org.meshtastic.desktop.** { *; }
+
+# Core model classes (used in serialization, Room, and Koin injection)
+-keep class org.meshtastic.core.model.** { *; }
+
+# ---- JVM runtime suppression ------------------------------------------------
+
-dontwarn java.lang.reflect.**
--dontwarn sun.misc.Unsafe
\ No newline at end of file
+-dontwarn sun.misc.Unsafe
+-dontwarn java.lang.invoke.**
+
+# ---- jSerialComm (cross-platform serial library with Android stubs) ---------
+
+-dontwarn com.fazecast.jSerialComm.android.**
+
+# ---- Kotlin stdlib atomics (Kotlin 2.3+ intrinsics, not on JDK 17) ----------
+
+-dontwarn kotlin.concurrent.atomics.**
+-dontwarn kotlin.uuid.UuidV7Generator
From 553ca2f8edb22801714b0180e0e171088f11d67d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:31:40 -0500
Subject: [PATCH 007/244] feat: implement global SnackbarManager and
consolidate common UI setup (#4909)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
AGENTS.md | 2 +-
GEMINI.md | 2 +-
.../kotlin/org/meshtastic/app/MainActivity.kt | 9 +-
.../main/kotlin/org/meshtastic/app/ui/Main.kt | 233 ++++--------------
.../core/navigation/NavBackStackExt.kt | 34 +++
.../core/ui/component/FirmwareVersionCheck.kt | 97 ++++++++
.../ui/component/MeshtasticCommonAppSetup.kt | 37 +++
.../ui/component/MeshtasticSnackbarHost.kt | 67 +++++
.../core/ui/component/SharedDialogs.kt | 27 +-
.../ui/component/TracerouteAlertHandler.kt | 98 ++++++++
.../core/ui/util/SnackbarManager.kt | 63 +++++
.../core/ui/viewmodel/UIViewModel.kt | 6 +
.../core/ui/util/SnackbarManagerTest.kt | 103 ++++++++
desktop/README.md | 7 +-
desktop/build.gradle.kts | 23 +-
.../desktop/DesktopNotificationManager.kt | 11 +-
.../kotlin/org/meshtastic/desktop/Main.kt | 40 +--
.../desktop/di/DesktopKoinModule.kt | 4 +
.../desktop/ui/DesktopMainScreen.kt | 105 ++++----
.../src/main/resources/tray_icon_black.svg | 13 +-
.../src/main/resources/tray_icon_white.svg | 13 +-
.../feature/node/detail/NodeDetailScreen.kt | 14 --
.../feature/node/metrics/PositionLog.kt | 18 --
.../node/detail/CommonNodeRequestActions.kt | 45 ++--
.../node/detail/NodeDetailViewModel.kt | 3 -
.../feature/node/detail/NodeRequestActions.kt | 7 -
.../feature/node/metrics/BaseMetricChart.kt | 4 -
.../feature/node/metrics/DeviceMetrics.kt | 15 --
.../node/metrics/EnvironmentMetrics.kt | 17 --
.../feature/node/metrics/HostMetricsLog.kt | 18 --
.../feature/node/metrics/MetricsViewModel.kt | 4 -
.../feature/node/metrics/NeighborInfoLog.kt | 17 --
.../feature/node/metrics/PaxMetrics.kt | 15 --
.../feature/node/metrics/PowerMetrics.kt | 15 --
.../feature/node/metrics/SignalMetrics.kt | 15 --
.../feature/node/metrics/TracerouteLog.kt | 17 --
.../node/detail/NodeDetailViewModelTest.kt | 1 -
.../node/metrics/MetricsViewModelTest.kt | 1 -
38 files changed, 705 insertions(+), 515 deletions(-)
create mode 100644 core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt
create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt
diff --git a/AGENTS.md b/AGENTS.md
index 82f6c153a..829ec4d12 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,7 +61,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
-- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
+- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
diff --git a/GEMINI.md b/GEMINI.md
index 0e2d85567..86f17e61b 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -61,7 +61,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
-- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
+- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index c191c576e..3bb562098 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -57,7 +57,6 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.toMeshtasticUri
-import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
@@ -217,11 +216,9 @@ class MainActivity : ComponentActivity() {
return
}
- uri.dispatchMeshtasticUri(
- onChannel = { model.setRequestChannelSet(it) },
- onContact = { model.setSharedContactRequested(it) },
- onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
- )
+ model.handleScannedUri(uri.toMeshtasticUri()) {
+ lifecycleScope.launch { showToast(Res.string.channel_invalid) }
+ }
}
private fun createShareIntent(message: String): PendingIntent {
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index 69eefcd30..a323cc997 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -24,14 +24,12 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -52,7 +50,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -63,42 +60,28 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
-import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
-import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
+import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
-import org.meshtastic.core.resources.firmware_old
-import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.must_update
-import org.meshtastic.core.resources.okay
-import org.meshtastic.core.resources.should_update
-import org.meshtastic.core.resources.should_update_firmware
-import org.meshtastic.core.resources.traceroute
-import org.meshtastic.core.resources.view_on_map
-import org.meshtastic.core.service.MeshService
-import org.meshtastic.core.ui.component.AlertHost
+import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
+import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
-import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
-import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
-import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
-import org.meshtastic.core.ui.util.annotateTraceroute
-import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
@@ -117,71 +100,16 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
// }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
- val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
- val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
- SharedDialogs(
- connectionState = connectionState,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
- onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
+ MeshtasticCommonAppSetup(
+ uiViewModel = uIViewModel,
+ onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
+ backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
+ },
)
- VersionChecks(uIViewModel)
-
- AlertHost(uIViewModel.alertManager)
-
- val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
- var dismissedTracerouteRequestId by remember { mutableStateOf(null) }
- traceRouteResponse
- ?.takeIf { it.requestId != dismissedTracerouteRequestId }
- ?.let { response ->
- uIViewModel.showAlert(
- titleRes = Res.string.traceroute,
- composableMessage = {
- Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
- Text(
- text =
- annotateTraceroute(
- response.message,
- statusGreen = colorScheme.StatusGreen,
- statusYellow = colorScheme.StatusYellow,
- statusOrange = colorScheme.StatusOrange,
- ),
- )
- }
- },
- confirmTextRes = Res.string.view_on_map,
- onConfirm = {
- val availability =
- uIViewModel.tracerouteMapAvailability(
- forwardRoute = response.forwardRoute,
- returnRoute = response.returnRoute,
- )
- val errorRes = availability.toMessageRes()
- if (errorRes == null) {
- dismissedTracerouteRequestId = response.requestId
- backStack.add(
- NodeDetailRoutes.TracerouteMap(
- destNum = response.destinationNodeNum,
- requestId = response.requestId,
- logUuid = response.logUuid,
- ),
- )
- } else {
- uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
- uIViewModel.clearTracerouteResponse()
- }
- },
- dismissTextRes = Res.string.okay,
- onDismiss = {
- uIViewModel.clearTracerouteResponse()
- dismissedTracerouteRequestId = null
- },
- )
- }
+ AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true),
@@ -280,96 +208,70 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
+ backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
+ backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
+ backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
- val provider =
- entryProvider {
- contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
- nodesGraph(
- backStack = backStack,
- scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
- nodeMapScreen = { destNum, onNavigateUp ->
- val vm =
- org.koin.compose.viewmodel.koinViewModel()
- vm.setDestNum(destNum)
- org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
- },
- )
- mapGraph(backStack)
- channelsGraph(backStack)
- connectionsGraph(backStack)
- settingsGraph(backStack)
- firmwareGraph(backStack)
- }
- NavDisplay(
- backStack = backStack,
- entryProvider = provider,
- modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
- )
+ MeshtasticSnackbarProvider(
+ snackbarManager = uIViewModel.snackbarManager,
+ hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
+ ) {
+ val provider =
+ entryProvider {
+ contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
+ nodesGraph(
+ backStack = backStack,
+ scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
+ nodeMapScreen = { destNum, onNavigateUp ->
+ val vm =
+ org.koin.compose.viewmodel.koinViewModel<
+ org.meshtastic.feature.map.node.NodeMapViewModel,
+ >()
+ vm.setDestNum(destNum)
+ org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
+ },
+ )
+ mapGraph(backStack)
+ channelsGraph(backStack)
+ connectionsGraph(backStack)
+ settingsGraph(backStack)
+ firmwareGraph(backStack)
+ }
+ NavDisplay(
+ backStack = backStack,
+ entryProvider = provider,
+ modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
+ )
+ }
}
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
-private fun VersionChecks(viewModel: UIViewModel) {
+private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
- val myFirmwareVersion = myNodeInfo?.firmwareVersion
-
- val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
-
- val latestStableFirmwareRelease by
- viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
- LaunchedEffect(connectionState, firmwareEdition) {
- if (connectionState == ConnectionState.Connected) {
- firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
- }
- }
-
- // Check if the device is running an old app version or firmware version
+ // Check if the device is running an old app version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) {
- Logger.i {
- "[FW_CHECK] Connection state: $connectionState, " +
- "myNodeInfo: ${if (myNodeInfo != null) "present" else "null"}, " +
- "firmwareVersion: ${myFirmwareVersion ?: "null"}"
- }
-
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
Logger.d {
@@ -384,49 +286,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
messageRes = Res.string.must_update,
onConfirm = { viewModel.setDeviceAddress("n") },
)
- } else {
- myFirmwareVersion
- ?.takeIf { it.isNotBlank() }
- ?.let { fwVersion ->
- val curVer = DeviceVersion(fwVersion)
- Logger.i {
- "[FW_CHECK] Firmware version comparison - " +
- "device: $curVer (raw: $fwVersion), " +
- "absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
- "min: ${MeshService.minDeviceVersion}"
- }
-
- if (curVer < MeshService.absoluteMinDeviceVersion) {
- Logger.w {
- "[FW_CHECK] Firmware too old - " +
- "device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
- }
- val title = getString(Res.string.firmware_too_old)
- val message = getString(Res.string.firmware_old)
- viewModel.showAlert(
- title = title,
- html = message,
- onConfirm = { viewModel.setDeviceAddress("n") },
- )
- } else if (curVer < MeshService.minDeviceVersion) {
- Logger.w {
- "[FW_CHECK] Firmware should update - " +
- "device: $curVer < min: ${MeshService.minDeviceVersion}"
- }
- val title = getString(Res.string.should_update_firmware)
- val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
- viewModel.showAlert(title = title, message = message, onConfirm = {})
- } else {
- Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
- }
- }
- ?: run {
- Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
- }
}
- } ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
- } else {
- Logger.d { "[FW_CHECK] Not connected (state: $connectionState), skipping firmware check" }
+ }
}
}
}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt
new file mode 100644
index 000000000..5638814f8
--- /dev/null
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.navigation
+
+import androidx.navigation3.runtime.NavKey
+
+/**
+ * Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
+ * root destination.
+ */
+fun MutableList.navigateTopLevel(route: NavKey) {
+ if (isNotEmpty()) {
+ this[0] = route
+ while (size > 1) {
+ removeAt(lastIndex)
+ }
+ } else {
+ add(route)
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt
new file mode 100644
index 000000000..2291ac9eb
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.core.ui.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import co.touchlab.kermit.Logger
+import org.jetbrains.compose.resources.getString
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceVersion
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.firmware_old
+import org.meshtastic.core.resources.firmware_too_old
+import org.meshtastic.core.resources.should_update
+import org.meshtastic.core.resources.should_update_firmware
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+
+/**
+ * Common component to check the connected device's firmware version against the minimum required version. Will display
+ * a dismissable alert if the firmware is old, or a blocking alert if it is too old.
+ */
+@Composable
+fun FirmwareVersionCheck(viewModel: UIViewModel) {
+ val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
+ val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
+
+ val myFirmwareVersion = myNodeInfo?.firmwareVersion
+
+ val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
+
+ val latestStableFirmwareRelease by
+ viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
+
+ LaunchedEffect(connectionState, firmwareEdition) {
+ if (connectionState == ConnectionState.Connected) {
+ firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
+ }
+ }
+
+ LaunchedEffect(connectionState, myNodeInfo) {
+ if (connectionState == ConnectionState.Connected) {
+ myNodeInfo?.let { info ->
+ myFirmwareVersion
+ ?.takeIf { it.isNotBlank() }
+ ?.let { fwVersion ->
+ val curVer = DeviceVersion(fwVersion)
+ Logger.i {
+ "[FW_CHECK] Firmware version comparison - " +
+ "device: $curVer (raw: $fwVersion), " +
+ "absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}, " +
+ "min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
+ }
+
+ if (curVer < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
+ Logger.w {
+ "[FW_CHECK] Firmware too old - " +
+ "device: $curVer < absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}"
+ }
+ val title = getString(Res.string.firmware_too_old)
+ val message = getString(Res.string.firmware_old)
+ viewModel.showAlert(
+ title = title,
+ html = message,
+ onConfirm = { viewModel.setDeviceAddress("n") },
+ )
+ } else if (curVer < DeviceVersion(DeviceVersion.MIN_FW_VERSION)) {
+ Logger.w {
+ "[FW_CHECK] Firmware should update - " +
+ "device: $curVer < min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
+ }
+ val title = getString(Res.string.should_update_firmware)
+ val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
+ viewModel.showAlert(title = title, message = message, onConfirm = {})
+ } else {
+ Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt
new file mode 100644
index 000000000..19e73495d
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.core.ui.component
+
+import androidx.compose.runtime.Composable
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+
+/**
+ * Encapsulates the headless, global UI components (dialogs, version checks, traceroute alerts) that need to be active
+ * across all platforms at the root of the application hierarchy.
+ *
+ * This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
+ */
+@Composable
+fun MeshtasticCommonAppSetup(
+ uiViewModel: UIViewModel,
+ onNavigateToTracerouteMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
+) {
+ SharedDialogs(uiViewModel = uiViewModel)
+ FirmwareVersionCheck(viewModel = uiViewModel)
+ AlertHost(alertManager = uiViewModel.alertManager)
+ TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap)
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt
new file mode 100644
index 000000000..6b6da135f
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.core.ui.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.ui.util.SnackbarManager
+
+/**
+ * Shared composable that observes [SnackbarManager.events] and provides a global [SnackbarHostState].
+ *
+ * It renders a [SnackbarHost] using the provided [hostModifier] over the provided [content].
+ */
+@Composable
+fun MeshtasticSnackbarProvider(
+ snackbarManager: SnackbarManager,
+ modifier: Modifier = Modifier,
+ hostModifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(snackbarManager) {
+ snackbarManager.events.collect { event ->
+ val result =
+ snackbarHostState.showSnackbar(
+ message = event.message,
+ actionLabel = event.actionLabel,
+ withDismissAction = event.withDismissAction,
+ duration = event.duration,
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ event.onAction?.invoke()
+ }
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ content()
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter).then(hostModifier),
+ )
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt
index 6a3e16dfe..c990c916e 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt
@@ -17,11 +17,12 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
-import org.meshtastic.proto.ChannelSet
-import org.meshtastic.proto.SharedContact
+import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
@@ -30,16 +31,18 @@ import org.meshtastic.proto.SharedContact
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
*/
@Composable
-fun SharedDialogs(
- connectionState: ConnectionState,
- sharedContactRequested: SharedContact?,
- requestChannelSet: ChannelSet?,
- onDismissSharedContact: () -> Unit,
- onDismissChannelSet: () -> Unit,
-) {
- if (connectionState == ConnectionState.Connected) {
- sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
+fun SharedDialogs(uiViewModel: UIViewModel) {
+ val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
+ val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
+ val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
- requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
+ if (connectionState == ConnectionState.Connected) {
+ sharedContactRequested?.let {
+ SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
+ }
+
+ requestChannelSet?.let { newChannelSet ->
+ ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
+ }
}
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
new file mode 100644
index 000000000..100c6fecb
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.core.ui.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.okay
+import org.meshtastic.core.resources.traceroute
+import org.meshtastic.core.resources.view_on_map
+import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
+import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
+import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
+import org.meshtastic.core.ui.util.annotateTraceroute
+import org.meshtastic.core.ui.util.toMessageRes
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+
+/**
+ * Handles the display of the traceroute alert when a response is received. Consolidates the side effect logic from the
+ * main application screens into common code.
+ */
+@Composable
+fun TracerouteAlertHandler(
+ uiViewModel: UIViewModel,
+ onNavigateToMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
+) {
+ val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
+ var dismissedTracerouteRequestId by remember { mutableStateOf(null) }
+ val colorScheme = MaterialTheme.colorScheme
+
+ LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) {
+ val response = traceRouteResponse
+ if (response != null && response.requestId != dismissedTracerouteRequestId) {
+ uiViewModel.showAlert(
+ titleRes = Res.string.traceroute,
+ composableMessage = {
+ Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ Text(
+ text =
+ annotateTraceroute(
+ response.message,
+ statusGreen = colorScheme.StatusGreen,
+ statusYellow = colorScheme.StatusYellow,
+ statusOrange = colorScheme.StatusOrange,
+ ),
+ )
+ }
+ },
+ confirmTextRes = Res.string.view_on_map,
+ onConfirm = {
+ val availability =
+ uiViewModel.tracerouteMapAvailability(
+ forwardRoute = response.forwardRoute,
+ returnRoute = response.returnRoute,
+ )
+ val errorRes = availability.toMessageRes()
+ if (errorRes == null) {
+ dismissedTracerouteRequestId = response.requestId
+ onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid)
+ } else {
+ uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
+ uiViewModel.clearTracerouteResponse()
+ }
+ },
+ dismissTextRes = Res.string.okay,
+ onDismiss = {
+ uiViewModel.clearTracerouteResponse()
+ dismissedTracerouteRequestId = null
+ },
+ )
+ }
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt
new file mode 100644
index 000000000..463b75f09
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.material3.SnackbarDuration
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import org.koin.core.annotation.Single
+
+/**
+ * A global manager for displaying snackbars across the application. This allows ViewModels to trigger transient
+ * feedback messages without direct dependencies on UI components or `SnackbarHostState`.
+ *
+ * Events are buffered in a [Channel] and consumed exactly once by the host composable via `MeshtasticSnackbarHost`.
+ *
+ * @see AlertManager for the modal dialog equivalent.
+ */
+@Single
+open class SnackbarManager {
+ data class SnackbarEvent(
+ val message: String,
+ val actionLabel: String? = null,
+ val withDismissAction: Boolean = false,
+ val duration: SnackbarDuration = SnackbarDuration.Short,
+ val onAction: (() -> Unit)? = null,
+ )
+
+ private val _events = Channel(Channel.BUFFERED)
+ open val events: Flow = _events.receiveAsFlow()
+
+ open fun showSnackbar(
+ message: String,
+ actionLabel: String? = null,
+ withDismissAction: Boolean = false,
+ duration: SnackbarDuration = if (actionLabel != null) SnackbarDuration.Indefinite else SnackbarDuration.Short,
+ onAction: (() -> Unit)? = null,
+ ) {
+ _events.trySend(
+ SnackbarEvent(
+ message = message,
+ actionLabel = actionLabel,
+ withDismissAction = withDismissAction,
+ duration = duration,
+ onAction = onAction,
+ ),
+ )
+ }
+}
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 9ff6239c8..6b743363f 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
@@ -57,6 +57,7 @@ import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
+import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
@@ -80,6 +81,7 @@ class UIViewModel(
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
val alertManager: AlertManager,
+ val snackbarManager: SnackbarManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow(replay = 1)
@@ -165,6 +167,10 @@ class UIViewModel(
alertManager.dismissAlert()
}
+ fun showSnackbar(message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
+ snackbarManager.showSnackbar(message = message, actionLabel = actionLabel, onAction = onAction)
+ }
+
fun setDeviceAddress(address: String) {
radioController.setDeviceAddress(address)
}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt
new file mode 100644
index 000000000..f53178aa9
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.material3.SnackbarDuration
+import app.cash.turbine.test
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SnackbarManagerTest {
+
+ private val snackbarManager = SnackbarManager()
+
+ @Test
+ fun showSnackbar_emits_event_with_message() = runTest {
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(message = "Hello")
+
+ val event = awaitItem()
+ assertEquals("Hello", event.message)
+ assertNull(event.actionLabel)
+ assertEquals(SnackbarDuration.Short, event.duration)
+ }
+ }
+
+ @Test
+ fun showSnackbar_with_action_defaults_to_indefinite_duration() = runTest {
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(message = "Deleted", actionLabel = "Undo")
+
+ val event = awaitItem()
+ assertEquals("Deleted", event.message)
+ assertEquals("Undo", event.actionLabel)
+ assertEquals(SnackbarDuration.Indefinite, event.duration)
+ }
+ }
+
+ @Test
+ fun showSnackbar_with_explicit_duration_overrides_default() = runTest {
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(message = "Saved", actionLabel = "View", duration = SnackbarDuration.Long)
+
+ val event = awaitItem()
+ assertEquals(SnackbarDuration.Long, event.duration)
+ }
+ }
+
+ @Test
+ fun multiple_events_are_queued_and_consumed_in_order() = runTest {
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(message = "First")
+ snackbarManager.showSnackbar(message = "Second")
+ snackbarManager.showSnackbar(message = "Third")
+
+ assertEquals("First", awaitItem().message)
+ assertEquals("Second", awaitItem().message)
+ assertEquals("Third", awaitItem().message)
+ }
+ }
+
+ @Test
+ fun onAction_callback_is_preserved_in_event() = runTest {
+ var actionTriggered = false
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(
+ message = "Item removed",
+ actionLabel = "Undo",
+ onAction = { actionTriggered = true },
+ )
+
+ val event = awaitItem()
+ event.onAction?.invoke()
+ assertTrue(actionTriggered)
+ }
+ }
+
+ @Test
+ fun withDismissAction_is_passed_through() = runTest {
+ snackbarManager.events.test {
+ snackbarManager.showSnackbar(message = "Notice", withDismissAction = true)
+
+ val event = awaitItem()
+ assertTrue(event.withDismissAction)
+ }
+ }
+}
diff --git a/desktop/README.md b/desktop/README.md
index ea17d0eb7..a981d2d2e 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -51,7 +51,9 @@ The module depends on the JVM variants of KMP modules:
**DI:** A Koin DI graph is bootstrapped in `Main.kt` with platform-specific implementations injected.
-**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules.
+**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. Includes native macOS notification support (via `TrayState` and `bundleID` identification) and a monochrome SVG tray icon for a native look and feel.
+
+**Notifications:** Implements the common `NotificationManager` interface via `DesktopNotificationManager`. Repository-level notifications (messages, node events, alerts) are collected in `Main.kt` and forwarded to the system tray. macOS requires a consistent `bundleID` (configured in `build.gradle.kts`) and the `NSUserNotificationAlertStyle` key in `Info.plist` for notifications to appear correctly in the distributable.
**Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack.
@@ -64,6 +66,8 @@ The module depends on the JVM variants of KMP modules:
| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` |
| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations (delegates to shared feature graphs) |
| `radio/DesktopRadioTransportFactory.kt` | Provides TCP, Serial/USB, and BLE transports |
+| `notification/DesktopMeshServiceNotifications.kt` | Real implementation of notification triggers for Desktop |
+| `DesktopNotificationManager.kt` | Bridge between repository notifications and Compose `TrayState` |
| `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain |
| `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets |
| `di/DesktopKoinModule.kt` | Koin module with stub implementations |
@@ -83,6 +87,7 @@ The module depends on the JVM variants of KMP modules:
- [x] Implement real navigation with shared `core:navigation` routes (Navigation 3 shell)
- [x] Adopt JetBrains multiplatform forks for lifecycle and navigation3
+- [x] Implement native macOS/Desktop notification support with `TrayState` and system tray
- [x] Wire `feature:settings` composables into the nav graph (first real feature — ~30 screens)
- [x] Wire `feature:node` composables into the nav graph (node list with shared ViewModel + NodeItem)
- [x] Wire `feature:messaging` composables into the nav graph (contacts list with shared ViewModel)
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index 1ffb0f96a..99a2079a8 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -43,6 +43,12 @@ tasks.withType().configureEach { exclude("**/generated/**") }
compose.desktop {
application {
mainClass = "org.meshtastic.desktop.MainKt"
+ jvmArgs(
+ "-Xmx2G",
+ "-Dapple.awt.application.name=Meshtastic",
+ "-Dcom.apple.mrj.application.apple.menu.about.name=Meshtastic",
+ "-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
+ )
buildTypes.release.proguard {
isEnabled.set(true)
@@ -66,15 +72,28 @@ compose.desktop {
// Default JVM arguments for the packaged application
// Increase max heap size to prevent OOM issues on complex maps/data
- jvmArgs("-Xmx2G")
+ jvmArgs(
+ "-Xmx2G",
+ "-Dapple.awt.application.name=Meshtastic",
+ "-Dcom.apple.mrj.application.apple.name=Meshtastic",
+ "-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
+ )
// App Icon & OS Specific Configurations
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
minimumSystemVersion = "12.0"
+ bundleID = "org.meshtastic.desktop"
+ infoPlist {
+ extraKeysRawXml =
+ """
+ NSUserNotificationAlertStyle
+ alert
+ """
+ .trimIndent()
+ }
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
// You can inject these from CI environment variables.
- // bundleID = "org.meshtastic.desktop"
// sign = true
// notarize = true
// appleID = System.getenv("APPLE_ID")
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 5a871efd6..86b1fb4db 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -27,6 +27,10 @@ import androidx.compose.ui.window.Notification as ComposeNotification
@Single
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
+ init {
+ co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
+ }
+
private val _notifications = MutableSharedFlow(extraBufferCapacity = 10)
val notifications: SharedFlow = _notifications.asSharedFlow()
@@ -40,6 +44,10 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Category.Service -> true
}
+ co.touchlab.kermit.Logger.d {
+ "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
+ }
+
if (!enabled) return
val composeType =
@@ -50,7 +58,8 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Type.Error -> ComposeNotification.Type.Error
}
- _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
+ val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
+ co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
}
override fun cancel(id: Int) {
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 96b121524..7e8962b49 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -66,6 +66,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
+import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
@@ -172,13 +173,21 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
val trayState = rememberTrayState()
val appIcon = classpathPainterResource("icon.png")
+ @Suppress("DEPRECATION")
+ val trayIcon =
+ androidx.compose.ui.res.painterResource(
+ if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
+ )
+
val notificationManager = remember { koinApp.koin.get() }
- val alertManager = remember { koinApp.koin.get() }
val desktopPrefs = remember { koinApp.koin.get() }
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
- notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
+ notificationManager.notifications.collect { notification ->
+ Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
+ trayState.sendNotification(notification)
+ }
}
LaunchedEffect(Unit) {
@@ -209,7 +218,9 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
Tray(
state = trayState,
- icon = appIcon,
+ icon = trayIcon,
+ tooltip = "Meshtastic Desktop",
+ onAction = { isAppVisible = true },
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item(
@@ -250,7 +261,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) {
- navigateTopLevel(backStack, TopLevelDestination.Settings.route)
+ backStack.navigateTopLevel(TopLevelDestination.Settings.route)
}
true
}
@@ -261,22 +272,22 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
}
// ⌘1 → Conversations
event.key == Key.One -> {
- navigateTopLevel(backStack, TopLevelDestination.Conversations.route)
+ backStack.navigateTopLevel(TopLevelDestination.Conversations.route)
true
}
// ⌘2 → Nodes
event.key == Key.Two -> {
- navigateTopLevel(backStack, TopLevelDestination.Nodes.route)
+ backStack.navigateTopLevel(TopLevelDestination.Nodes.route)
true
}
// ⌘3 → Map
event.key == Key.Three -> {
- navigateTopLevel(backStack, TopLevelDestination.Map.route)
+ backStack.navigateTopLevel(TopLevelDestination.Map.route)
true
}
// ⌘4 → Connections
event.key == Key.Four -> {
- navigateTopLevel(backStack, TopLevelDestination.Connections.route)
+ backStack.navigateTopLevel(TopLevelDestination.Connections.route)
true
}
// ⌘/ → About
@@ -310,19 +321,8 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
// 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) {
- org.meshtastic.core.ui.component.AlertHost(alertManager)
- DesktopMainScreen(backStack)
- }
+ AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) }
}
}
}
}
-
-/** Replaces the backstack with a single top-level destination route. */
-private fun navigateTopLevel(backStack: MutableList, route: NavKey) {
- backStack.add(route)
- while (backStack.size > 1) {
- backStack.removeAt(0)
- }
-}
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 21b9ed84c..efb8f5740 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -137,6 +137,10 @@ private fun desktopPlatformStubsModule() = module {
locationManager = get(),
)
}
+ single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) }
+ single {
+ get()
+ }
single {
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
}
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 fff4df006..7099781e3 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
@@ -16,8 +16,10 @@
*/
package org.meshtastic.desktop.ui
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
@@ -27,6 +29,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -36,10 +39,12 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.TopLevelDestination
+import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.ui.component.AlertHost
-import org.meshtastic.core.ui.component.SharedDialogs
+import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
+import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
@@ -51,6 +56,7 @@ import org.meshtastic.desktop.navigation.desktopNavGraph
* app, proving the shared backstack architecture works across targets.
*/
@Composable
+@Suppress("LongMethod")
fun DesktopMainScreen(
backStack: NavBackStack,
radioService: RadioInterfaceService = koinInject(),
@@ -63,61 +69,60 @@ fun DesktopMainScreen(
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
- val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
- val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
-
- SharedDialogs(
- connectionState = connectionState,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
- onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
+ MeshtasticCommonAppSetup(
+ uiViewModel = uiViewModel,
+ onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
+ backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
+ },
)
- AlertHost(uiViewModel.alertManager)
-
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
- Row(modifier = Modifier.fillMaxSize()) {
- NavigationRail {
- TopLevelDestination.entries.forEach { destination ->
- NavigationRailItem(
- selected = destination == selected,
- onClick = {
- if (destination != selected) {
- backStack.add(destination.route)
- while (backStack.size > 1) {
- backStack.removeAt(0)
+ Box(modifier = Modifier.fillMaxSize()) {
+ Row(modifier = Modifier.fillMaxSize()) {
+ NavigationRail {
+ TopLevelDestination.entries.forEach { destination ->
+ NavigationRailItem(
+ selected = destination == selected,
+ onClick = {
+ if (destination != selected) {
+ backStack.navigateTopLevel(destination.route)
}
- }
- },
- icon = {
- if (destination == TopLevelDestination.Connections) {
- org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
- connectionState = connectionState,
- deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
- meshActivityFlow = radioService.meshActivity,
- colorScheme = colorScheme,
- )
- } else {
- Icon(
- imageVector = destination.icon,
- contentDescription = stringResource(destination.label),
- )
- }
- },
- label = { Text(stringResource(destination.label)) },
+ },
+ icon = {
+ if (destination == TopLevelDestination.Connections) {
+ org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
+ connectionState = connectionState,
+ deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
+ meshActivityFlow = radioService.meshActivity,
+ colorScheme = colorScheme,
+ )
+ } else {
+ Icon(
+ imageVector = destination.icon,
+ contentDescription = stringResource(destination.label),
+ )
+ }
+ },
+ label = { Text(stringResource(destination.label)) },
+ )
+ }
+ }
+
+ MeshtasticSnackbarProvider(
+ snackbarManager = uiViewModel.snackbarManager,
+ modifier = Modifier.weight(1f).fillMaxSize(),
+ hostModifier = Modifier.padding(bottom = 24.dp),
+ ) {
+ val provider = entryProvider { desktopNavGraph(backStack) }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = provider,
+ modifier = Modifier.fillMaxSize(),
)
}
}
-
- val provider = entryProvider { desktopNavGraph(backStack) }
-
- NavDisplay(
- backStack = backStack,
- onBack = { backStack.removeLastOrNull() },
- entryProvider = provider,
- modifier = Modifier.weight(1f).fillMaxSize(),
- )
}
}
}
diff --git a/desktop/src/main/resources/tray_icon_black.svg b/desktop/src/main/resources/tray_icon_black.svg
index bf1a8916e..451ae8562 100644
--- a/desktop/src/main/resources/tray_icon_black.svg
+++ b/desktop/src/main/resources/tray_icon_black.svg
@@ -1,12 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/desktop/src/main/resources/tray_icon_white.svg b/desktop/src/main/resources/tray_icon_white.svg
index 89bf128f4..451ae8562 100644
--- a/desktop/src/main/resources/tray_icon_white.svg
+++ b/desktop/src/main/resources/tray_icon_white.svg
@@ -1,12 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index 0d673afd9..853017d94 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -26,8 +26,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -81,21 +79,11 @@ actual fun NodeDetailScreen(
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
- val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- LaunchedEffect(Unit) {
- viewModel.effects.collect { effect ->
- if (effect is NodeRequestEffect.ShowFeedback) {
- snackbarHostState.showSnackbar(effect.text.resolve())
- }
- }
- }
-
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
- snackbarHostState = snackbarHostState,
viewModel = viewModel,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
@@ -109,7 +97,6 @@ actual fun NodeDetailScreen(
private fun NodeDetailScaffold(
modifier: Modifier,
uiState: NodeDetailUiState,
- snackbarHostState: SnackbarHostState,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
@@ -139,7 +126,6 @@ private fun NodeDetailScaffold(
onClickChip = {},
)
},
- snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
NodeDetailContent(
uiState = uiState,
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
index 2ed2fa7cd..5862a0ed9 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
@@ -36,15 +36,11 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -64,7 +60,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.theme.AppTheme
-import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@@ -104,18 +99,6 @@ private fun ActionButtons(
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
- val snackbarHostState = remember { SnackbarHostState() }
-
- LaunchedEffect(Unit) {
- viewModel.effects.collect { effect ->
- when (effect) {
- is NodeRequestEffect.ShowFeedback -> {
- @Suppress("SpreadOperator")
- snackbarHostState.showSnackbar(effect.text.resolve())
- }
- }
- }
- }
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -144,7 +127,6 @@ actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
onClickChip = {},
)
},
- snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
index 2ec8c9d50..ce4bfcf9a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
@@ -18,11 +18,8 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -46,12 +43,14 @@ import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
+import org.meshtastic.core.ui.util.SnackbarManager
@Single(binds = [NodeRequestActions::class])
-class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions {
-
- private val _effects = MutableSharedFlow()
- override val effects: SharedFlow = _effects.asSharedFlow()
+class CommonNodeRequestActions
+constructor(
+ private val radioController: RadioController,
+ private val snackbarManager: SnackbarManager,
+) : NodeRequestActions {
private val _lastTracerouteTime = MutableStateFlow(null)
override val lastTracerouteTime: StateFlow = _lastTracerouteTime.asStateFlow()
@@ -59,15 +58,15 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
private val _lastRequestNeighborTimes = MutableStateFlow