Skip to content

Commit 88f4d5d

Browse files
committed
android: extend support for protocol navigation to Android
Extends support for the `navigate` path of the `tailscale:` protocol to Android, mirroring the existing macOS routes & pending iOS routes. All settings tabs without existing routes simply open the main settings view for now. ``` tailscale://navigate/main/devices tailscale://navigate/main/devices/<computedName or stableID> tailscale://navigate/main/exit-nodes tailscale://navigate/main/exit-nodes/<stableID> tailscale://navigate/main/exit-nodes/location/<country> tailscale://navigate/settings[/<anyTab>] ``` The complete list of registered routes can be retrieved with the added `scripts/deeplink-probe.sh` script, passing "--routes" or "-l". This script can further be used to trigger the application to navigate to a given route by passing that to it. e.g., `scripts/deeplink-probe.sh settings/about` updates tailscale/corp#41056 Signed-off-by: Will Hannah <willh@tailscale.com>
1 parent d53001c commit 88f4d5d

5 files changed

Lines changed: 291 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ libtailscale-sources.jar
5151
.DS_Store
5252

5353
tailscale.version
54+
55+
# .tmp folder
56+
.tmp/

android/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@
5757
<intent-filter>
5858
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
5959
</intent-filter>
60+
<intent-filter>
61+
<action android:name="android.intent.action.VIEW" />
62+
<category android:name="android.intent.category.DEFAULT" />
63+
<category android:name="android.intent.category.BROWSABLE" />
64+
<data android:scheme="tailscale" android:host="navigate" />
65+
</intent-filter>
6066
</activity>
6167
<activity
6268
android:name="ShareActivity"

android/src/main/java/com/tailscale/ipn/MainActivity.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import com.tailscale.ipn.ui.model.Ipn
6262
import com.tailscale.ipn.ui.notifier.Notifier
6363
import com.tailscale.ipn.ui.theme.AppTheme
6464
import com.tailscale.ipn.ui.util.AndroidTVUtil
65+
import com.tailscale.ipn.ui.util.DeepLinkNavigator
6566
import com.tailscale.ipn.ui.util.set
6667
import com.tailscale.ipn.ui.util.universalFit
6768
import com.tailscale.ipn.ui.view.AboutView
@@ -114,6 +115,8 @@ class MainActivity : ComponentActivity() {
114115
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
115116
private lateinit var appViewModel: AppViewModel
116117
private lateinit var viewModel: MainViewModel
118+
private var deepLinkNavigator: DeepLinkNavigator? = null
119+
private var pendingDeepLink: Uri? = null
117120

118121
val permissionsViewModel: PermissionsViewModel by viewModels()
119122

@@ -136,6 +139,8 @@ class MainActivity : ComponentActivity() {
136139
override fun onCreate(savedInstanceState: Bundle?) {
137140
super.onCreate(savedInstanceState)
138141

142+
pendingDeepLink = navigateUri(intent)
143+
139144
// grab app to make sure it initializes
140145
App.get()
141146
appViewModel = (application as App).getAppScopedViewModel()
@@ -253,6 +258,15 @@ class MainActivity : ComponentActivity() {
253258

254259
navController = rememberNavController()
255260

261+
LaunchedEffect(navController) {
262+
val nav = DeepLinkNavigator(navController, lifecycleScope)
263+
deepLinkNavigator = nav
264+
pendingDeepLink?.let {
265+
pendingDeepLink = null
266+
nav.handle(it)
267+
}
268+
}
269+
256270
AppTheme {
257271
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
258272
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
@@ -483,6 +497,21 @@ class MainActivity : ComponentActivity() {
483497
}
484498
}
485499
}
500+
navigateUri(intent)?.let { uri ->
501+
val nav = deepLinkNavigator
502+
if (nav != null) {
503+
nav.handle(uri)
504+
} else {
505+
pendingDeepLink = uri
506+
}
507+
}
508+
}
509+
510+
private fun navigateUri(intent: Intent?): Uri? {
511+
if (intent?.action != Intent.ACTION_VIEW) return null
512+
val uri = intent.data ?: return null
513+
if (uri.scheme != "tailscale" || uri.host != "navigate") return null
514+
return uri
486515
}
487516

488517
private fun login(urlString: String) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.util
5+
6+
import android.net.Uri
7+
import androidx.navigation.NavHostController
8+
import com.tailscale.ipn.ui.localapi.Client
9+
import com.tailscale.ipn.ui.model.Ipn
10+
import com.tailscale.ipn.ui.model.Tailcfg
11+
import com.tailscale.ipn.ui.notifier.Notifier
12+
import com.tailscale.ipn.util.TSLog
13+
import kotlinx.coroutines.CoroutineScope
14+
15+
// URL shape (mirrors iOS / macOS):
16+
// tailscale://navigate/main/devices
17+
// tailscale://navigate/main/devices/<computedName|stableID>
18+
// tailscale://navigate/main/exit-nodes
19+
// tailscale://navigate/main/exit-nodes/<stableID>
20+
// tailscale://navigate/main/exit-nodes/location/<country>
21+
// tailscale://navigate/settings[/<subRoute>]
22+
class DeepLinkNavigator(
23+
private val navController: NavHostController,
24+
private val scope: CoroutineScope,
25+
) {
26+
companion object {
27+
private const val TAG = "DeepLinkNavigator"
28+
private val settingsSubRoutes =
29+
setOf(
30+
"about",
31+
"bugReport",
32+
"dnsSettings",
33+
"splitTunneling",
34+
"tailnetLock",
35+
"subnetRouting",
36+
"mdmSettings",
37+
"managedBy",
38+
"userSwitcher",
39+
"permissions",
40+
)
41+
}
42+
43+
fun handle(uri: Uri): Boolean {
44+
if (uri.host != "navigate") return false
45+
val segments = uri.pathSegments
46+
val window = segments.firstOrNull() ?: return false
47+
val tail = segments.drop(1)
48+
49+
return when (window) {
50+
"main" -> handleMain(tail)
51+
"settings" -> {
52+
presentSettings(tail.firstOrNull()?.takeIf { it in settingsSubRoutes })
53+
true
54+
}
55+
else -> false
56+
}
57+
}
58+
59+
private fun handleMain(tail: List<String>): Boolean {
60+
val tab = tail.firstOrNull() ?: return false
61+
val rest = tail.drop(1)
62+
63+
return when (tab) {
64+
"devices" ->
65+
when (rest.size) {
66+
0 -> {
67+
showDeviceList()
68+
true
69+
}
70+
1 -> {
71+
pushDeviceDetail(rest[0])
72+
true
73+
}
74+
else -> false
75+
}
76+
"exit-nodes" ->
77+
when {
78+
rest.isEmpty() -> {
79+
presentExitNodePicker()
80+
true
81+
}
82+
rest.size == 1 -> {
83+
selectExitNode(rest[0])
84+
true
85+
}
86+
rest.size == 2 && rest[0] == "location" -> {
87+
selectExitNodeCountry(rest[1])
88+
true
89+
}
90+
else -> false
91+
}
92+
else -> false
93+
}
94+
}
95+
96+
private fun showDeviceList() {
97+
popToMain()
98+
}
99+
100+
private fun pushDeviceDetail(identifier: String) {
101+
val node = findNode(identifier)
102+
if (node == null) {
103+
TSLog.d(TAG, "Deep link: device not found for '$identifier'")
104+
return
105+
}
106+
popToMain()
107+
navController.navigate("peerDetails/${node.StableID}")
108+
}
109+
110+
// Matches macOS: ComputedName (case-insensitive) first, then StableID.
111+
private fun findNode(identifier: String): Tailcfg.Node? {
112+
val netmap = Notifier.netmap.value ?: return null
113+
val all = netmap.Peers.orEmpty() + netmap.SelfNode
114+
return all.firstOrNull { identifier.equals(it.ComputedName, ignoreCase = true) }
115+
?: all.firstOrNull { it.StableID == identifier }
116+
}
117+
118+
private fun presentExitNodePicker() {
119+
popToMain()
120+
navController.navigate("exitNodes")
121+
}
122+
123+
private fun selectExitNode(identifier: String) {
124+
val peers = Notifier.netmap.value?.Peers.orEmpty()
125+
val match = peers.firstOrNull { it.StableID == identifier && it.isExitNode }
126+
if (match == null) {
127+
TSLog.d(TAG, "Deep link: exit node not found for '$identifier'")
128+
presentExitNodePicker()
129+
return
130+
}
131+
val prefs = Ipn.MaskedPrefs()
132+
prefs.ExitNodeID = match.StableID
133+
Client(scope).editPrefs(prefs) { result ->
134+
result.onFailure { TSLog.e(TAG, "Deep link: editPrefs failed", it) }
135+
}
136+
}
137+
138+
// Mirrors iOS: open the picker rather than threading a country-specific destination.
139+
private fun selectExitNodeCountry(country: String) {
140+
presentExitNodePicker()
141+
TSLog.d(TAG, "Deep link: requested exit-node country '$country' (presenting picker)")
142+
}
143+
144+
private fun presentSettings(subRoute: String? = null) {
145+
popToMain()
146+
navController.navigate("settings")
147+
subRoute?.let { navController.navigate(it) }
148+
}
149+
150+
private fun popToMain() {
151+
navController.popBackStack(route = "main", inclusive = false)
152+
}
153+
}

scripts/deeplink-probe.sh

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Copyright (c) Tailscale Inc & AUTHORS
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
#
6+
# deeplink-probe.sh fires a tailscale://navigate/<path> URI at an attached
7+
# device three ways (BROWSABLE implicit, bare implicit, explicit component)
8+
# and tails the relevant logcat lines so you can confirm DeepLinkNavigator
9+
# saw the intent. Pass --routes to list the URI templates the app handles.
10+
11+
set -uo pipefail
12+
13+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14+
NAV_KT="${SCRIPT_DIR}/../android/src/main/java/com/tailscale/ipn/ui/util/DeepLinkNavigator.kt"
15+
16+
show_routes() {
17+
if [ ! -f "$NAV_KT" ]; then
18+
echo "Could not locate DeepLinkNavigator.kt at $NAV_KT" >&2
19+
return 1
20+
fi
21+
22+
# Header comment lists URI templates as `// tailscale://...`.
23+
local templates
24+
templates=$(awk '/^\/\/[[:space:]]+tailscale:\/\//{ sub(/^\/\/[[:space:]]+/, ""); print }' "$NAV_KT")
25+
26+
# `settingsSubRoutes = setOf("about", "bugReport", ...)` — pull the quoted names.
27+
local sub_routes
28+
sub_routes=$(awk '
29+
/settingsSubRoutes[[:space:]]*=/ { flag=1 }
30+
flag { print }
31+
flag && /\)/ { exit }
32+
' "$NAV_KT" | grep -oE '"[A-Za-z]+"' | tr -d '"')
33+
34+
echo "Known DeepLinkNavigator routes (parsed from $(basename "$NAV_KT")):"
35+
echo
36+
while IFS= read -r tpl; do
37+
[ -z "$tpl" ] && continue
38+
if [[ "$tpl" == *"[/<subRoute>]"* ]]; then
39+
local base="${tpl%\[/*}"
40+
echo " $base"
41+
while IFS= read -r r; do
42+
[ -z "$r" ] && continue
43+
echo " $base/$r"
44+
done <<< "$sub_routes"
45+
else
46+
echo " $tpl"
47+
fi
48+
done <<< "$templates"
49+
50+
echo
51+
echo "Usage: $(basename "$0") [--routes] [path-after-navigate]"
52+
echo " default path: main/devices"
53+
}
54+
55+
case "${1:-}" in
56+
--routes|-l|--list)
57+
show_routes
58+
exit 0
59+
;;
60+
-h|--help)
61+
show_routes
62+
exit 0
63+
;;
64+
esac
65+
66+
PATH_TAIL="${1:-main/devices}"
67+
URI="tailscale://navigate/${PATH_TAIL}"
68+
OUT_DIR="$(cd "$(dirname "$0")" && pwd)"
69+
OUT_FILE="${OUT_DIR}/deeplink-probe.log"
70+
71+
echo "URI: ${URI}"
72+
echo "Logging to ${OUT_FILE}"
73+
echo
74+
75+
adb logcat -c
76+
77+
{
78+
echo "=== implicit (BROWSABLE) ==="
79+
adb shell "am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d '${URI}'"
80+
echo
81+
82+
echo "=== implicit (no category) ==="
83+
adb shell "am start -W -a android.intent.action.VIEW -d '${URI}'"
84+
echo
85+
86+
echo "=== explicit component ==="
87+
adb shell "am start -W -n com.tailscale.ipn/.MainActivity -a android.intent.action.VIEW -d '${URI}'"
88+
echo
89+
} | tee "${OUT_FILE}"
90+
91+
sleep 1
92+
93+
{
94+
echo
95+
echo "=== logcat ==="
96+
adb logcat -d -v brief | grep -E "Main Activity|DeepLinkNavigator"
97+
} | tee -a "${OUT_FILE}"
98+
99+
echo
100+
echo "Full output: ${OUT_FILE}"

0 commit comments

Comments
 (0)