diff --git a/AGENTS.md b/AGENTS.md
index 829ec4d12..830d9912d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
+ - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
diff --git a/GEMINI.md b/GEMINI.md
index 86f17e61b..e07a2f79e 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -18,7 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
+ - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
diff --git a/README.md b/README.md
index b0e9ec1c7..5aa7ebef0 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ The app follows modern Android development practices, built on top of a shared K
- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
-- **Navigation:** JetBrains Navigation 3 (Multiplatform routing).
+- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a8c0bb94b..07973ae0d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -218,6 +218,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 3bb562098..6a1f7ebd0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -211,14 +211,8 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
- model.handleNavigationDeepLink(uri.toMeshtasticUri())
- return
- }
- model.handleScannedUri(uri.toMeshtasticUri()) {
- lifecycleScope.launch { showToast(Res.string.channel_invalid) }
- }
+ model.handleDeepLink(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 a323cc997..12a7a6ee3 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -97,8 +97,17 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
- // LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
- // }
+
+ LaunchedEffect(uIViewModel) {
+ uIViewModel.navigationDeepLink.collect { uri ->
+ val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
+ org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
+ backStack.clear()
+ backStack.addAll(navKeys)
+ }
+ }
+ }
+
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
@@ -239,6 +248,7 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
nodesGraph(
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
+ onHandleDeepLink = uIViewModel::handleDeepLink,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
index 1abbead11..9b0977a2e 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -26,9 +26,11 @@ kotlin {
sourceSets {
commonMain.dependencies {
+ implementation(projects.core.common)
implementation(projects.core.resources)
implementation(libs.kotlinx.serialization.core)
implementation(libs.jetbrains.navigation3.ui)
+ implementation(libs.kermit)
}
commonTest.dependencies { implementation(kotlin("test")) }
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
new file mode 100644
index 000000000..23deaf6aa
--- /dev/null
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
@@ -0,0 +1,213 @@
+/*
+ * 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
+import co.touchlab.kermit.Logger
+import org.meshtastic.core.common.util.CommonUri
+
+/**
+ * Type-safe deep link parser for KMP Navigation 3.
+ *
+ * Maps an incoming OS intent URI to a list of NavKeys representing the target backstack. This ensures that when a user
+ * deep links into a detail view, the logical "up" hierarchy is synthesized and correctly populated in the user-owned
+ * NavBackStack list.
+ *
+ * Supports both legacy query-parameter URIs and modern RESTful path patterns:
+ * - `/nodes` -> List of all nodes
+ * - `/nodes/{destNum}` -> Node details
+ * - `/nodes/{destNum}/{metric}` -> Specific node metric (e.g., `/nodes/1234/device-metrics`)
+ * - `/messages` -> Conversation list
+ * - `/messages/{contactKey}` -> Specific conversation
+ * - `/settings` -> Settings root
+ * - `/settings/{destNum}/{page}` -> Specific settings page for a node
+ * - `/share?message={text}` -> Share message screen
+ */
+object DeepLinkRouter {
+ /**
+ * Synthesizes a backstack list from an incoming Meshtastic URI.
+ *
+ * @param uri The incoming OS intent URI (e.g. "meshtastic://meshtastic/share?message=hello")
+ * @return A list of strongly-typed NavKeys representing the backstack, or null if the URI is not recognized.
+ */
+ fun route(uri: CommonUri): List? {
+ val pathSegments = uri.pathSegments.filter { it.isNotBlank() }
+
+ if (pathSegments.isEmpty()) {
+ return null
+ }
+
+ val firstSegment = pathSegments[0].lowercase()
+
+ return when (firstSegment) {
+ "share",
+ "messages",
+ "quickchat",
+ -> routeContacts(uri, pathSegments)
+ "connections" -> listOf(ConnectionsRoutes.ConnectionsGraph)
+ "map" -> routeMap(uri, pathSegments)
+ "nodes" -> routeNodes(uri, pathSegments)
+ "settings" -> routeSettings(pathSegments)
+ "channels" -> listOf(ChannelsRoutes.ChannelsGraph)
+ "firmware" -> routeFirmware(pathSegments)
+ else -> {
+ Logger.w { "Unrecognized deep link segment: $firstSegment" }
+ null
+ }
+ }
+ }
+
+ private fun routeContacts(uri: CommonUri, segments: List): List {
+ val firstSegment = segments[0].lowercase()
+ return when (firstSegment) {
+ "share" -> {
+ val message = uri.getQueryParameter("message") ?: ""
+ listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.Share(message))
+ }
+ "quickchat" -> {
+ listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.QuickChat)
+ }
+ "messages" -> {
+ val contactKey = if (segments.size > 1) segments[1] else uri.getQueryParameter("contactKey") ?: ""
+ val message = uri.getQueryParameter("message") ?: ""
+ if (contactKey.isNotBlank()) {
+ listOf(
+ ContactsRoutes.ContactsGraph,
+ ContactsRoutes.Messages(contactKey = contactKey, message = message),
+ )
+ } else {
+ listOf(ContactsRoutes.ContactsGraph)
+ }
+ }
+ else -> listOf(ContactsRoutes.ContactsGraph)
+ }
+ }
+
+ private fun routeMap(uri: CommonUri, segments: List): List {
+ val waypointIdStr = if (segments.size > 1) segments[1] else uri.getQueryParameter("waypointId")
+ val waypointId = waypointIdStr?.toIntOrNull()
+ return listOf(MapRoutes.Map(waypointId))
+ }
+
+ private fun routeNodes(uri: CommonUri, segments: List): List {
+ val destNumStr = if (segments.size > 1) segments[1] else uri.getQueryParameter("destNum")
+ val destNum = destNumStr?.toIntOrNull()
+
+ return if (destNum == null) {
+ listOf(NodesRoutes.NodesGraph)
+ } else if (segments.size > 2) {
+ val subRouteStr = segments[2].lowercase()
+ val detailRouteFn = nodeDetailSubRoutes[subRouteStr]
+ if (detailRouteFn != null) {
+ listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetailGraph(destNum), detailRouteFn(destNum))
+ } else {
+ listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum))
+ }
+ } else {
+ listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum))
+ }
+ }
+
+ private fun routeSettings(segments: List): List {
+ var destNum: Int? = null
+ var subRouteStr: String? = null
+
+ if (segments.size > 1) {
+ val secondSegment = segments[1]
+ val parsedNum = secondSegment.toIntOrNull()
+ if (parsedNum != null) {
+ destNum = parsedNum
+ if (segments.size > 2) {
+ subRouteStr = segments[2].lowercase()
+ }
+ } else {
+ subRouteStr = secondSegment.lowercase()
+ }
+ }
+
+ if (subRouteStr == null) {
+ return listOf(SettingsRoutes.SettingsGraph(destNum))
+ }
+
+ val subRoute = settingsSubRoutes[subRouteStr]
+ return if (subRoute != null) {
+ listOf(SettingsRoutes.SettingsGraph(destNum), subRoute)
+ } else {
+ listOf(SettingsRoutes.SettingsGraph(destNum))
+ }
+ }
+
+ private fun routeFirmware(segments: List): List {
+ val update = if (segments.size > 1) segments[1].lowercase() == "update" else false
+ return if (update) {
+ listOf(FirmwareRoutes.FirmwareGraph, FirmwareRoutes.FirmwareUpdate)
+ } else {
+ listOf(FirmwareRoutes.FirmwareGraph)
+ }
+ }
+
+ private val settingsSubRoutes: Map =
+ mapOf(
+ "device-config" to SettingsRoutes.DeviceConfiguration,
+ "module-config" to SettingsRoutes.ModuleConfiguration,
+ "admin" to SettingsRoutes.Administration,
+ "user" to SettingsRoutes.User,
+ "channel" to SettingsRoutes.ChannelConfig,
+ "device" to SettingsRoutes.Device,
+ "position" to SettingsRoutes.Position,
+ "power" to SettingsRoutes.Power,
+ "network" to SettingsRoutes.Network,
+ "display" to SettingsRoutes.Display,
+ "lora" to SettingsRoutes.LoRa,
+ "bluetooth" to SettingsRoutes.Bluetooth,
+ "security" to SettingsRoutes.Security,
+ "mqtt" to SettingsRoutes.MQTT,
+ "serial" to SettingsRoutes.Serial,
+ "ext-notification" to SettingsRoutes.ExtNotification,
+ "store-forward" to SettingsRoutes.StoreForward,
+ "range-test" to SettingsRoutes.RangeTest,
+ "telemetry" to SettingsRoutes.Telemetry,
+ "canned-message" to SettingsRoutes.CannedMessage,
+ "audio" to SettingsRoutes.Audio,
+ "remote-hardware" to SettingsRoutes.RemoteHardware,
+ "neighbor-info" to SettingsRoutes.NeighborInfo,
+ "ambient-lighting" to SettingsRoutes.AmbientLighting,
+ "detection-sensor" to SettingsRoutes.DetectionSensor,
+ "paxcounter" to SettingsRoutes.Paxcounter,
+ "status-message" to SettingsRoutes.StatusMessage,
+ "traffic-management" to SettingsRoutes.TrafficManagement,
+ "tak" to SettingsRoutes.TAK,
+ "clean-node-db" to SettingsRoutes.CleanNodeDb,
+ "debug-panel" to SettingsRoutes.DebugPanel,
+ "about" to SettingsRoutes.About,
+ "filter-settings" to SettingsRoutes.FilterSettings,
+ )
+
+ private val nodeDetailSubRoutes: Map Route> =
+ mapOf(
+ "device-metrics" to { destNum -> NodeDetailRoutes.DeviceMetrics(destNum) },
+ "map" to { destNum -> NodeDetailRoutes.NodeMap(destNum) },
+ "position" to { destNum -> NodeDetailRoutes.PositionLog(destNum) },
+ "environment" to { destNum -> NodeDetailRoutes.EnvironmentMetrics(destNum) },
+ "signal" to { destNum -> NodeDetailRoutes.SignalMetrics(destNum) },
+ "power" to { destNum -> NodeDetailRoutes.PowerMetrics(destNum) },
+ "traceroute" to { destNum -> NodeDetailRoutes.TracerouteLog(destNum) },
+ "host-metrics" to { destNum -> NodeDetailRoutes.HostMetricsLog(destNum) },
+ "pax" to { destNum -> NodeDetailRoutes.PaxMetrics(destNum) },
+ "neighbors" to { destNum -> NodeDetailRoutes.NeighborInfoLog(destNum) },
+ )
+}
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index f9a5a4116..d411a2b65 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -55,6 +55,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.navigationevent.compose)
+ implementation(libs.jetbrains.navigation3.ui)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
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
index 19e73495d..c9e761e7a 100644
--- 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
@@ -20,10 +20,16 @@ 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.
+ * Common application-level setup for all Meshtastic platforms (Android, Desktop, etc.).
*
- * This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
+ * This component encapsulates headless global UI logic that must reside at the root of the application hierarchy. It
+ * manages:
+ * - Shared system dialogs (e.g. contact/channel import)
+ * - Global version and firmware checks
+ * - System-wide alerts and snackbar hosts
+ * - Deep link navigation interception logic
+ *
+ * Platform hosts (Main.kt) should invoke this at the root of their theme before rendering the main NavDisplay.
*/
@Composable
fun MeshtasticCommonAppSetup(
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 6b743363f..3fff4a03f 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
@@ -87,18 +87,30 @@ class UIViewModel(
private val _navigationDeepLink = MutableSharedFlow(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
- fun handleNavigationDeepLink(uri: MeshtasticUri) {
- _navigationDeepLink.tryEmit(uri)
- }
+ /**
+ * Unified handler for all Meshtastic deep links and OS intents.
+ *
+ * This method orchestrates two distinct types of URI handling:
+ * 1. **Navigation:** First attempts to parse the URI into a typed [NavKey] backstack via [DeepLinkRouter]. If
+ * successful, navigates the user to the target screen.
+ * 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
+ * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
+ */
+ fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
+ val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
- /** Unified handler for scanned Meshtastic URIs (contacts or channels). */
- fun handleScannedUri(uri: MeshtasticUri, onInvalid: () -> Unit) {
- org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
- .dispatchMeshtasticUri(
- onContact = { setSharedContactRequested(it) },
- onChannel = { setRequestChannelSet(it) },
- onInvalid = onInvalid,
- )
+ // Try navigation routing first
+ if (org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) != null) {
+ _navigationDeepLink.tryEmit(uri)
+ return
+ }
+
+ // Fallback to channel/contact importing
+ commonUri.dispatchMeshtasticUri(
+ onContact = { setSharedContactRequested(it) },
+ onChannel = { setRequestChannelSet(it) },
+ onInvalid = onInvalid,
+ )
}
val theme: StateFlow = uiPrefs.theme
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 7e8962b49..e326c102d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -129,7 +129,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
arg.startsWith("http://meshtastic.org") ||
arg.startsWith("https://meshtastic.org")
) {
- uiViewModel.handleScannedUri(MeshtasticUri(arg)) {
+ uiViewModel.handleDeepLink(MeshtasticUri(arg)) {
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
}
}
@@ -140,7 +140,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
Desktop.getDesktop().setOpenURIHandler { event ->
val uriStr = event.uri.toString()
- uiViewModel.handleScannedUri(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
+ uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
}
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index d92a33366..6ca876ff6 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
@@ -36,10 +36,14 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
* shared composables are wired.
*/
-fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack) {
+fun EntryProviderScope.desktopNavGraph(
+ backStack: NavBackStack,
+ uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel,
+) {
// Nodes — real composables from feature:node
nodesGraph(
backStack = backStack,
+ onHandleDeepLink = uiViewModel::handleDeepLink,
nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") },
)
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 7099781e3..9de71059e 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
@@ -27,6 +27,7 @@ import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -65,6 +66,16 @@ fun DesktopMainScreen(
val currentKey = backStack.lastOrNull()
val selected = TopLevelDestination.fromNavKey(currentKey)
+ LaunchedEffect(uiViewModel) {
+ uiViewModel.navigationDeepLink.collect { uri ->
+ val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
+ org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
+ backStack.clear()
+ backStack.addAll(navKeys)
+ }
+ }
+ }
+
val connectionState by radioService.connectionState.collectAsStateWithLifecycle()
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
@@ -113,7 +124,7 @@ fun DesktopMainScreen(
modifier = Modifier.weight(1f).fillMaxSize(),
hostModifier = Modifier.padding(bottom = 24.dp),
) {
- val provider = entryProvider { desktopNavGraph(backStack) }
+ val provider = entryProvider { desktopNavGraph(backStack, uiViewModel) }
NavDisplay(
backStack = backStack,
diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
index 1682c31e2..2767375b2 100644
--- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
+++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
@@ -35,6 +35,8 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- Do mutate `NavBackStack` with `add(...)` and `removeLastOrNull()`.
- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI.
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
+- Don't parse deep links manually in platform code or push single routes without a backstack.
+- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths.
### Current code anchors (Navigation 3)
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
index 79cfd92b4..4fee4383f 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
+++ b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
@@ -50,7 +50,7 @@ actual fun ContactsEntryContent(
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
- onHandleScannedUri = uiViewModel::handleScannedUri,
+ onHandleDeepLink = uiViewModel::handleDeepLink,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index 07184c60b..06dd0c69a 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -52,7 +52,7 @@ fun AdaptiveContactsScreen(
scrollToTopEvents: Flow,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
+ onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
@@ -96,7 +96,7 @@ fun AdaptiveContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
- onHandleScannedUri = onHandleScannedUri,
+ onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index 8be27f165..30a80fad4 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -115,7 +115,7 @@ fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
+ onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
@@ -253,7 +253,7 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
- onHandleScannedUri(MeshtasticUri(uriString)) {
+ onHandleDeepLink(MeshtasticUri(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
diff --git a/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
index 182b79276..66522d125 100644
--- a/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
+++ b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
@@ -42,7 +42,7 @@ actual fun ContactsEntryContent(
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = null,
requestChannelSet = null,
- onHandleScannedUri = { _, _ -> },
+ onHandleDeepLink = { _, _ -> },
onClearSharedContactRequested = {},
onClearRequestChannelUrl = {},
initialContactKey = initialContactKey,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 17d2934a0..ec88674b3 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -57,11 +57,9 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
-import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
-import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@@ -71,6 +69,7 @@ fun NodeListScreen(
onNavigateToChannels: () -> Unit = {},
scrollToTopEvents: Flow? = null,
activeNodeId: Int? = null,
+ onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@@ -100,9 +99,6 @@ fun NodeListScreen(
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
- val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
- requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
-
Scaffold(
topBar = {
MainAppBar(
@@ -118,14 +114,13 @@ fun NodeListScreen(
},
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
- val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
if (!isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable) {
MeshtasticImportFAB(
- sharedContact = sharedContact,
onImport = { uriString ->
- viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } }
+ onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) {
+ scope.launch { showToast(Res.string.channel_invalid) }
+ }
},
- onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index c486b3ca6..df65a3477 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -20,18 +20,14 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -40,7 +36,6 @@ import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
-import org.meshtastic.proto.SharedContact
@Suppress("LongParameterList")
@KoinViewModel
@@ -63,12 +58,6 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
- private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null)
- val sharedContactRequested = _sharedContactRequested.asStateFlow()
-
- private val _requestChannelSet = MutableStateFlow(null)
- val requestChannelSet = _requestChannelSet.asStateFlow()
-
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@@ -135,24 +124,6 @@ class NodeListViewModel(
nodeFilterPreferences.setNodeSort(sort)
}
- fun setSharedContactRequested(sharedContact: SharedContact?) {
- _sharedContactRequested.value = sharedContact
- }
-
- /** Unified handler for scanned Meshtastic URIs (contacts or channels). */
- fun handleScannedUri(uriString: String, onInvalid: () -> Unit) {
- val uri = CommonUri.parse(uriString)
- uri.dispatchMeshtasticUri(
- onContact = { _sharedContactRequested.value = it },
- onChannel = { _requestChannelSet.value = it },
- onInvalid = onInvalid,
- )
- }
-
- fun clearRequestChannelSet() {
- _requestChannelSet.value = null
- }
-
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newLoraConfig = channelSet.lora_config
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
index 9f3bba39a..6316ec715 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
@@ -52,6 +52,7 @@ fun AdaptiveNodeListScreen(
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
+ onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
val navigator = rememberListDetailPaneScaffoldNavigator()
@@ -85,6 +86,7 @@ fun AdaptiveNodeListScreen(
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = activeNodeId,
+ onHandleDeepLink = onHandleDeepLink,
)
},
detailPane = { contentKey, handleBack ->
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index e7729a983..fc5f647df 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -67,6 +67,7 @@ import kotlin.reflect.KClass
fun EntryProviderScope.nodesGraph(
backStack: NavBackStack,
scrollToTopEvents: Flow = MutableSharedFlow(),
+ onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry {
@@ -75,6 +76,7 @@ fun EntryProviderScope.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
+ onHandleDeepLink = onHandleDeepLink,
)
}
@@ -84,16 +86,18 @@ fun EntryProviderScope.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
+ onHandleDeepLink = onHandleDeepLink,
)
}
- nodeDetailGraph(backStack, scrollToTopEvents, nodeMapScreen)
+ nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, nodeMapScreen)
}
@Suppress("LongMethod")
fun EntryProviderScope.nodeDetailGraph(
backStack: NavBackStack,
scrollToTopEvents: Flow,
+ onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
) {
entry { args ->
@@ -103,6 +107,7 @@ fun EntryProviderScope.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
+ onHandleDeepLink = onHandleDeepLink,
)
}
@@ -113,6 +118,7 @@ fun EntryProviderScope.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
+ onHandleDeepLink = onHandleDeepLink,
)
}