Skip to content

Commit ae7c18d

Browse files
jamesarichclaude
andauthored
fix(nav): register /wifi-provision and /discovery as https App Links (#6365)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 78ba069 commit ae7c18d

4 files changed

Lines changed: 146 additions & 15 deletions

File tree

androidApp/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@
263263
<data android:pathPrefix="/settings" />
264264
<data android:pathPrefix="/channels" />
265265
<data android:pathPrefix="/firmware" />
266+
<data android:pathPrefix="/wifi-provision" />
267+
<data android:pathPrefix="/discovery" />
266268
</intent-filter>
267269

268270
<intent-filter>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.app.ui
18+
19+
import org.meshtastic.core.common.util.CommonUri
20+
import org.meshtastic.core.navigation.DeepLinkRouter
21+
import org.w3c.dom.Element
22+
import java.io.File
23+
import javax.xml.parsers.DocumentBuilderFactory
24+
import kotlin.test.Test
25+
import kotlin.test.assertNotNull
26+
import kotlin.test.assertTrue
27+
import kotlin.test.fail
28+
29+
/**
30+
* Guards against drift between [DeepLinkRouter] and the https App Links intent-filter (`android:autoVerify="true"`,
31+
* host `meshtastic.org`) in `androidApp/src/main/AndroidManifest.xml`.
32+
*
33+
* Every top-level path segment routed by [DeepLinkRouter.route] must be declared as an `android:pathPrefix` in that
34+
* filter — otherwise `https://meshtastic.org/{path}` links open in the browser instead of the app, even though the
35+
* `meshtastic://` scheme works. The segments come straight from [DeepLinkRouter.topLevelPathSegments], the set
36+
* [DeepLinkRouter.route] gates its dispatch on, so a new router segment fails here until the manifest declares it.
37+
*/
38+
class DeepLinkManifestConsistencyTest {
39+
40+
@Test
41+
fun `every canonical segment is actually routed by DeepLinkRouter`() {
42+
DeepLinkRouter.topLevelPathSegments.forEach { segment ->
43+
assertNotNull(
44+
DeepLinkRouter.route(CommonUri.parse("https://meshtastic.org/$segment")),
45+
"DeepLinkRouter.topLevelPathSegments lists /$segment but route() has no branch for it — " +
46+
"add the branch or remove the segment from the set and the manifest",
47+
)
48+
}
49+
}
50+
51+
@Test
52+
fun `app links intent filter declares a pathPrefix for every routed segment`() {
53+
val prefixes = appLinkPathPrefixes()
54+
DeepLinkRouter.topLevelPathSegments.forEach { segment ->
55+
assertTrue(
56+
"/$segment" in prefixes,
57+
"AndroidManifest.xml autoVerify filter is missing <data android:pathPrefix=\"/$segment\" /> — " +
58+
"https://meshtastic.org/$segment will open in the browser instead of the app",
59+
)
60+
}
61+
}
62+
63+
/** Collects the pathPrefix values of the autoVerify (App Links) intent-filter for meshtastic.org. */
64+
private fun appLinkPathPrefixes(): Set<String> {
65+
val manifest = manifestFile()
66+
val factory =
67+
DocumentBuilderFactory.newInstance().apply {
68+
// Harden against XXE even though we only parse our own manifest.
69+
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
70+
setFeature("http://xml.org/sax/features/external-general-entities", false)
71+
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
72+
isXIncludeAware = false
73+
isExpandEntityReferences = false
74+
}
75+
val document = factory.newDocumentBuilder().parse(manifest)
76+
val filters = document.getElementsByTagName("intent-filter")
77+
val prefixes = mutableSetOf<String>()
78+
for (i in 0 until filters.length) {
79+
val filter = filters.item(i) as Element
80+
if (filter.getAttribute("android:autoVerify") != "true") continue
81+
val dataElements = filter.getElementsByTagName("data")
82+
val attrs = (0 until dataElements.length).map { dataElements.item(it) as Element }
83+
if (attrs.none { it.getAttribute("android:host") == "meshtastic.org" }) continue
84+
if (attrs.none { it.getAttribute("android:scheme") == "https" }) continue
85+
attrs.mapNotNullTo(prefixes) { it.getAttribute("android:pathPrefix").ifEmpty { null } }
86+
}
87+
if (prefixes.isEmpty()) fail("No https App Links intent-filter for meshtastic.org found in ${manifest.path}")
88+
return prefixes
89+
}
90+
91+
private fun manifestFile(): File = listOf("src/main/AndroidManifest.xml", "androidApp/src/main/AndroidManifest.xml")
92+
.map(::File)
93+
.firstOrNull(File::exists)
94+
?: fail("Could not locate AndroidManifest.xml from working directory ${File(".").absolutePath}")
95+
}

core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,35 @@ import org.meshtastic.core.common.util.CommonUri
4242
* `address=n` disconnects instead of connecting.
4343
*/
4444
object DeepLinkRouter {
45+
/**
46+
* Canonical set of top-level path segments this router dispatches on. [route] refuses segments outside this set, so
47+
* a new `when` branch stays dead (and its feature tests fail) until its segment is added here. Every entry must
48+
* also be declared as an `android:pathPrefix` in the https App Links intent-filter in
49+
* `androidApp/src/main/AndroidManifest.xml` — DeepLinkManifestConsistencyTest (androidApp unit tests) enforces that
50+
* directly from this set.
51+
*/
52+
val topLevelPathSegments: Set<String> =
53+
setOf(
54+
"share",
55+
"messages",
56+
"quickchat",
57+
"connections",
58+
"discovery",
59+
"map",
60+
"nodes",
61+
"settings",
62+
"channels",
63+
"firmware",
64+
"wifi-provision",
65+
)
66+
67+
/**
68+
* Legacy import path segments (`/e/` = channel set, `/v/` = shared contact, matched case-insensitively). These are
69+
* handled by the `dispatchMeshtasticUri` fallback rather than this router, so [route] returns null for them without
70+
* logging a warning.
71+
*/
72+
private val legacyImportSegments = setOf("e", "v")
73+
4574
/**
4675
* Synthesizes a backstack list from an incoming Meshtastic URI.
4776
*
@@ -50,13 +79,17 @@ object DeepLinkRouter {
5079
*/
5180
fun route(uri: CommonUri): List<NavKey>? {
5281
val pathSegments = uri.pathSegments.filter { it.isNotBlank() }
82+
val firstSegment = pathSegments.firstOrNull()?.lowercase()
5383

54-
if (pathSegments.isEmpty()) {
84+
if (firstSegment !in topLevelPathSegments) {
85+
// /e/ and /v/ are channel-set/contact import links, not navigation routes: returning null here lets
86+
// callers fall back to dispatchMeshtasticUri (see UIViewModel.handleDeepLink), so don't warn on them.
87+
if (firstSegment != null && firstSegment !in legacyImportSegments) {
88+
Logger.w { "Unrecognized deep link segment: $firstSegment" }
89+
}
5590
return null
5691
}
5792

58-
val firstSegment = pathSegments[0].lowercase()
59-
6093
return when (firstSegment) {
6194
"share",
6295
"messages",
@@ -79,10 +112,8 @@ object DeepLinkRouter {
79112

80113
"wifi-provision" -> routeWifiProvision(uri)
81114

82-
else -> {
83-
Logger.w { "Unrecognized deep link segment: $firstSegment" }
84-
null
85-
}
115+
// Unreachable: gated on topLevelPathSegments above.
116+
else -> null
86117
}
87118
}
88119

docs/en/developer/navigation-and-deep-links.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,27 @@ sealed interface SettingsRoute : Route {
4747

4848
### URI Format
4949

50-
Both forms resolve through the same `DeepLinkRouter`:
50+
Both forms resolve through the same `DeepLinkRouter`, so any path below works with either scheme:
5151

5252
```text
5353
meshtastic://meshtastic/{path}
5454
https://meshtastic.org/{path} # App Link, android:autoVerify — also opens in-app on a real device/adb
5555
```
5656

57-
The `meshtastic://` scheme accepts every path below. The `https://` App Link only covers the path
58-
prefixes declared in the manifest intent-filter (`/share`, `/connections`, `/map`, `/messages`,
59-
`/quickchat`, `/nodes`, `/settings`, `/channels`, `/firmware`) — notably `/wifi-provision` and
60-
`/discovery` currently resolve only via the custom scheme.
61-
6257
`adb shell am start -a android.intent.action.VIEW -d "meshtastic://meshtastic/{path}"` is the fastest way to
6358
trigger any route below from a shell or automation script without touching the UI.
6459

65-
**Source of truth:** the always-current list of segments lives in
60+
For the `https` form to open in-app, each top-level path segment must also be declared as an
61+
`android:pathPrefix` in the `android:autoVerify` intent-filter in `androidApp/src/main/AndroidManifest.xml`
62+
otherwise the link opens in the browser. Adding a new top-level route therefore takes three steps: add the
63+
segment to `DeepLinkRouter.topLevelPathSegments` (the router refuses to dispatch segments outside that set),
64+
add its `when` branch in `DeepLinkRouter.route()`, and add the matching `pathPrefix` to the manifest.
65+
`DeepLinkManifestConsistencyTest` (androidApp unit tests) checks the manifest against the set, so a missing
66+
manifest entry fails CI.
67+
68+
**Source of truth:** the always-current list of top-level segments is `topLevelPathSegments` in
6669
[`DeepLinkRouter`](https://github.com/meshtastic/Meshtastic-Android/blob/main/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt)
67-
— the `route()` `when` block plus its helper maps (`settingsSubRoutes`, `nodeDetailSubRoutes`);
70+
sub-paths live in the `route()` `when` block plus its helper maps (`settingsSubRoutes`, `nodeDetailSubRoutes`);
6871
the class-level KDoc is illustrative, not exhaustive. It also exists as executable spec in
6972
[`DeepLinkRouterTest.kt`](https://github.com/meshtastic/Meshtastic-Android/blob/main/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt).
7073
The table below is a snapshot for quick reference — check those two files if it looks out of date.

0 commit comments

Comments
 (0)