mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-03 01:45:36 -04:00
feat(connections): add deep link to trigger a connection by address (#6036)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<ConnectionsRoute.Connections> {
|
||||
entry<ConnectionsRoute.Connections> { key ->
|
||||
val scanModel = koinViewModel<ScannerViewModel>()
|
||||
// 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<ScannerViewModel>(),
|
||||
scanModel = scanModel,
|
||||
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
|
||||
onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
|
||||
onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) },
|
||||
|
||||
Reference in New Issue
Block a user