From b0e91a390c149d3e274988252bfb56ec96b55e54 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:21:24 -0500 Subject: [PATCH] feat: implement unified deep link routing for Kotlin Multiplatform (#4910) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- AGENTS.md | 2 +- GEMINI.md | 2 +- README.md | 2 +- app/src/main/AndroidManifest.xml | 21 ++ .../kotlin/org/meshtastic/app/MainActivity.kt | 8 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 14 +- core/navigation/build.gradle.kts | 2 + .../core/navigation/DeepLinkRouter.kt | 213 ++++++++++++++++++ core/ui/build.gradle.kts | 1 + .../ui/component/MeshtasticCommonAppSetup.kt | 12 +- .../core/ui/viewmodel/UIViewModel.kt | 34 ++- .../kotlin/org/meshtastic/desktop/Main.kt | 4 +- .../desktop/navigation/DesktopNavigation.kt | 6 +- .../desktop/ui/DesktopMainScreen.kt | 13 +- .../di-navigation3-anti-patterns-playbook.md | 2 + .../navigation/ContactsEntryContent.kt | 2 +- .../ui/contact/AdaptiveContactsScreen.kt | 4 +- .../feature/messaging/ui/contact/Contacts.kt | 4 +- .../navigation/ContactsEntryContent.kt | 2 +- .../feature/node/list/NodeListScreen.kt | 13 +- .../feature/node/list/NodeListViewModel.kt | 29 --- .../node/navigation/AdaptiveNodeListScreen.kt | 2 + .../node/navigation/NodesNavigation.kt | 8 +- 23 files changed, 325 insertions(+), 75 deletions(-) create mode 100644 core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt 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, ) }