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 index af98567c3..90dae94bd 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -37,6 +37,9 @@ import org.meshtastic.core.common.util.CommonUri * - `/settings/{destNum}/{page}` -> Specific settings page for a node * - `/wifi-provision` -> WiFi provisioning screen * - `/wifi-provision?address={mac}` -> WiFi provisioning targeting a specific device MAC address + * - `/connections?address={prefixedAddress}` -> Connections screen, auto-connecting to a prefixed device address (e.g. + * `t192.168.1.1:4403` for TCP, `xAA:BB:CC:DD:EE:FF` for BLE) — lets external tooling trigger a connection. + * `address=n` disconnects instead of connecting. */ object DeepLinkRouter { /** @@ -60,7 +63,7 @@ object DeepLinkRouter { "quickchat", -> routeContacts(uri, pathSegments) - "connections" -> listOf(ConnectionsRoute.Connections) + "connections" -> listOf(ConnectionsRoute.Connections(uri.getQueryParameter("address"))) "map" -> routeMap(uri, pathSegments) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index e412a2949..6450727d7 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -32,7 +32,13 @@ sealed interface ChannelsRoute : Route { @Serializable sealed interface ConnectionsRoute : Route { - @Serializable data object Connections : ConnectionsRoute, Graph + /** + * @param address Optional prefixed device address (e.g. `t192.168.1.1:4403`, `xAA:BB:CC:DD:EE:FF`) to auto-connect + * to when this route is deep-linked into, e.g. `/connections?address=t192.168.1.1:4403`. + */ + @Serializable data class Connections(val address: String? = null) : + ConnectionsRoute, + Graph } @Serializable diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt index 27250c75a..726ef30fd 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt @@ -36,7 +36,7 @@ enum class TopLevelDestination(val label: StringResource, val route: Route) { Nodes(Res.string.nodes, NodesRoute.Nodes), Map(Res.string.map, MapRoute.Map()), Settings(Res.string.bottom_nav_settings, SettingsRoute.Settings()), - Connect(Res.string.connect, ConnectionsRoute.Connections), + Connect(Res.string.connect, ConnectionsRoute.Connections()), ; companion object { diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt index d2f53a1ca..0465cccf8 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt @@ -97,7 +97,20 @@ class DeepLinkRouterTest { @Test fun `connections routes to Connections`() { - assertEquals(listOf(ConnectionsRoute.Connections), route("/connections")) + assertEquals(listOf(ConnectionsRoute.Connections()), route("/connections")) + } + + @Test + fun `connections with address query param routes to Connections with address`() { + assertEquals( + listOf(ConnectionsRoute.Connections("t192.168.1.1:4403")), + route("/connections?address=t192.168.1.1:4403"), + ) + } + + @Test + fun `connections with n address routes to Connections with disconnect sentinel`() { + assertEquals(listOf(ConnectionsRoute.Connections("n")), route("/connections?address=n")) } // endregion @@ -418,7 +431,7 @@ class DeepLinkRouterTest { @Test fun `route segments are case insensitive`() { assertEquals(listOf(NodesRoute.Nodes), route("/Nodes")) - assertEquals(listOf(ConnectionsRoute.Connections), route("/CONNECTIONS")) + assertEquals(listOf(ConnectionsRoute.Connections()), route("/CONNECTIONS")) } // endregion diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt index 64640e823..638c15629 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt @@ -40,7 +40,8 @@ class NavigationConfigTest { // ChannelsRoute ChannelsRoute.Channels, // ConnectionsRoute - ConnectionsRoute.Connections, + ConnectionsRoute.Connections(), + ConnectionsRoute.Connections(address = "t192.168.1.1:4403"), // ContactsRoute ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = "test-contact", message = "hello"), @@ -176,6 +177,7 @@ class NavigationConfigTest { MapRoute.Map() to MapRoute.Map(waypointId = null), NodesRoute.NodeDetail() to NodesRoute.NodeDetail(destNum = null), SettingsRoute.Settings() to SettingsRoute.Settings(destNum = null), + ConnectionsRoute.Connections() to ConnectionsRoute.Connections(address = null), WifiProvisionRoute.WifiProvision() to WifiProvisionRoute.WifiProvision(address = null), ) diff --git a/docs/en/developer/navigation-and-deep-links.md b/docs/en/developer/navigation-and-deep-links.md index 45bc5e6b1..d0d33bfef 100644 --- a/docs/en/developer/navigation-and-deep-links.md +++ b/docs/en/developer/navigation-and-deep-links.md @@ -2,7 +2,7 @@ title: Navigation & Deep Links parent: Developer Guide nav_order: 4 -last_updated: 2026-05-13 +last_updated: 2026-07-01 aliases: - deeplinks - navigation-3 @@ -47,22 +47,48 @@ sealed interface SettingsRoute : Route { ### URI Format -``` +Both forms resolve through the same `DeepLinkRouter`, so any path below works with either scheme: + +```text meshtastic://meshtastic/{path} +https://meshtastic.org/{path} # App Link, android:autoVerify — also opens in-app on a real device/adb ``` +`adb shell am start -a android.intent.action.VIEW -d "meshtastic://meshtastic/{path}"` is the fastest way to +trigger any route below from a shell or automation script without touching the UI. + +**Source of truth:** the exhaustive, always-current list of segments lives as KDoc on +[`DeepLinkRouter.route()`](../../../core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt) +and as executable spec in +[`DeepLinkRouterTest.kt`](../../../core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt). +The table below is a snapshot for quick reference — check those two files if it looks out of date. + ### Supported Deep Links | URI Path | Route | Notes | |----------|-------|-------| +| `/connections` | `ConnectionsRoute.Connections(null)` | Connections screen | +| `/connections?address={prefixedAddress}` | `ConnectionsRoute.Connections(address)` | Auto-connects to a device without manual selection — the address uses the app's internal transport-prefixed format: `t192.168.1.1:4403` (TCP), `xAA:BB:CC:DD:EE:FF` (BLE), `s/dev/ttyUSB0` (serial). Intended for scripts/AI tooling driving the app. | +| `/connections?address=n` | `ConnectionsRoute.Connections("n")` | Disconnects the current device instead of connecting (`n` = the internal "no device selected" sentinel). | +| `/wifi-provision` | `WifiProvisionRoute.WifiProvision(null)` | WiFi provisioning screen | +| `/wifi-provision?address={mac}` | `WifiProvisionRoute.WifiProvision(mac)` | Provisioning targeting a specific device MAC | | `/settings` | `SettingsRoute.Settings(null)` | Settings root | | `/settings/helpDocs` | `SettingsRoute.HelpDocs` | Docs browser | | `/settings/helpDocs/{pageId}` | `SettingsRoute.HelpDocPage(pageId)` | Specific doc page | | `/settings/help-docs` | `SettingsRoute.HelpDocs` | Compatibility alias | +| `/settings/local-mesh-discovery/session/{sessionId}` | `DiscoveryRoute.DiscoverySummary(sessionId)` | Discovery session result | | `/nodes` | `NodesRoute.Nodes` | Node list | | `/nodes/{destNum}` | `NodesRoute.NodeDetail(destNum)` | Node detail | -| `/messages/{contactKey}` | `ContactsRoute.Messages(contactKey)` | Conversation | +| `/nodes/{destNum}/{metric}` | e.g. `NodeDetailRoute.DeviceMetrics(destNum)` | Specific node metric tab (`device-metrics`, `signal`, `power`, `traceroute`, `pax`, `neighbors`, ...) | +| `/messages` | `ContactsRoute.Contacts` | Conversation list | +| `/messages/{contactKey}` | `ContactsRoute.Messages(contactKey)` | Specific conversation | +| `/share?message={text}` | `ContactsRoute.Share(message)` | Share-to-contact composer | +| `/quickchat` | `ContactsRoute.QuickChat` | Quick chat picker | | `/map` | `MapRoute.Map(null)` | Map view | +| `/map/{waypointId}` | `MapRoute.Map(waypointId)` | Map centered on a waypoint | +| `/channels` | `ChannelsRoute.Channels` | Channel list | +| `/firmware` | `FirmwareRoute.FirmwareGraph` | Firmware screen | +| `/firmware/update` | `FirmwareRoute.FirmwareUpdate` | Firmware update flow | ### Backstack Synthesis @@ -85,6 +111,7 @@ This ensures the user can navigate "up" correctly. 2. Add the mapping in `DeepLinkRouter.settingsSubRoutes` (or equivalent for other graphs). 3. Add a test in `DeepLinkRouterTest.kt`. 4. Register the navigation entry in the appropriate feature module. +5. Update the KDoc list on `DeepLinkRouter.route()` and the table above — they're the two places tooling/agents look to discover what deep links exist. ## Navigation Entry Registration diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 23fa20d59..59f81e1fd 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -16,21 +16,31 @@ */ package org.meshtastic.feature.connections.navigation +import androidx.compose.runtime.LaunchedEffect import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ConnectionsRoute import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.feature.connections.NO_DEVICE_SELECTED import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.ui.ConnectionsScreen import org.meshtastic.feature.settings.radio.RadioConfigViewModel /** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoute.Connections]. */ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) { - entry { + entry { key -> + val scanModel = koinViewModel() + // Lets a deep link (e.g. from AI/automation tooling) trigger a connection, or a disconnect via `n`, without + // manual device selection. + LaunchedEffect(key.address) { + key.address?.takeIf(String::isNotBlank)?.let { address -> + if (address == NO_DEVICE_SELECTED) scanModel.disconnect() else scanModel.changeDeviceAddress(address) + } + } ConnectionsScreen( - scanModel = koinViewModel(), + scanModel = scanModel, radioConfigViewModel = koinViewModel(), onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) },