Skip to content

Commit 43147d3

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 `scripts/deeplink-probe.sh` script can be used to trigger the application to navigate to a given route by passing that to it. e.g., `./tool/go run ./scripts/deeplink-probe.go main/devices` updates tailscale/corp#41056 Signed-off-by: Will Hannah <willh@tailscale.com>
1 parent d53001c commit 43147d3

5 files changed

Lines changed: 244 additions & 1 deletion

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: 36 additions & 1 deletion
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 = deepLinkUri(intent)
143+
139144
// grab app to make sure it initializes
140145
App.get()
141146
appViewModel = (application as App).getAppScopedViewModel()
@@ -253,6 +258,17 @@ class MainActivity : ComponentActivity() {
253258

254259
navController = rememberNavController()
255260

261+
LaunchedEffect(navController) {
262+
val nav = DeepLinkNavigator(navController)
263+
deepLinkNavigator = nav
264+
pendingDeepLink?.let {
265+
pendingDeepLink = null
266+
if (!nav.handle(it)) {
267+
TSLog.d(TAG, "Deep link handler returned false for $it")
268+
}
269+
}
270+
}
271+
256272
AppTheme {
257273
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
258274
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
@@ -404,7 +420,9 @@ class MainActivity : ComponentActivity() {
404420
}
405421
}
406422
if (isIntroScreenViewedSet()) {
407-
navController.navigate("intro")
423+
if (pendingDeepLink == null) {
424+
navController.navigate("intro")
425+
}
408426
setIntroScreenViewed(true)
409427
}
410428
}
@@ -483,6 +501,23 @@ class MainActivity : ComponentActivity() {
483501
}
484502
}
485503
}
504+
deepLinkUri(intent)?.let { uri ->
505+
val nav = deepLinkNavigator
506+
if (nav != null) {
507+
if (!nav.handle(uri)) {
508+
TSLog.d(TAG, "Deep link handler returned false for $uri")
509+
}
510+
} else {
511+
pendingDeepLink = uri
512+
}
513+
}
514+
}
515+
516+
private fun deepLinkUri(intent: Intent?): Uri? {
517+
if (intent?.action != Intent.ACTION_VIEW) return null
518+
val uri = intent.data ?: return null
519+
if (uri.scheme != "tailscale" || uri.host != "navigate") return null
520+
return uri
486521
}
487522

488523
private fun login(urlString: String) {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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.model.Tailcfg
9+
import com.tailscale.ipn.ui.notifier.Notifier
10+
import com.tailscale.ipn.util.TSLog
11+
12+
// Deep links must only navigate — never perform an action on the user's
13+
// behalf. Tails whose leaf would imply an action (e.g., a specific exit
14+
// node) are accepted for URL compatibility but resolve to the parent
15+
// view; the user picks and confirms in-app.
16+
class DeepLinkNavigator(private val navController: NavHostController) {
17+
companion object {
18+
private const val TAG = "DeepLinkNavigator"
19+
}
20+
21+
fun handle(uri: Uri): Boolean {
22+
if (uri.host != "navigate") return false
23+
val segments = uri.pathSegments
24+
val window = segments.firstOrNull() ?: return false
25+
val tail = segments.drop(1)
26+
27+
return when (window) {
28+
"main" -> handleMain(tail)
29+
"settings" -> {
30+
showSettings()
31+
true
32+
}
33+
else -> false
34+
}
35+
}
36+
37+
private fun handleMain(tail: List<String>): Boolean {
38+
val tab = tail.firstOrNull() ?: return false
39+
val rest = tail.drop(1)
40+
41+
return when (tab) {
42+
"devices" ->
43+
when (rest.size) {
44+
0 -> {
45+
showDeviceList()
46+
true
47+
}
48+
1 -> pushDeviceDetail(rest[0])
49+
else -> false
50+
}
51+
"exit-nodes" ->
52+
when {
53+
rest.size <= 1 || (rest.size == 2 && rest[0] == "location") -> {
54+
showExitNodePicker()
55+
true
56+
}
57+
else -> false
58+
}
59+
else -> false
60+
}
61+
}
62+
63+
private fun showDeviceList() {
64+
popToMain()
65+
}
66+
67+
private fun pushDeviceDetail(identifier: String): Boolean {
68+
val node = findNode(identifier)
69+
if (node == null) {
70+
TSLog.d(TAG, "Deep link: device not found for '$identifier'")
71+
return false
72+
}
73+
navigateOverMain("peerDetails/${node.StableID}")
74+
return true
75+
}
76+
77+
private fun findNode(identifier: String): Tailcfg.Node? {
78+
val netmap = Notifier.netmap.value ?: return null
79+
val all = netmap.Peers.orEmpty() + netmap.SelfNode
80+
return all.firstOrNull { identifier.equals(it.ComputedName, ignoreCase = true) }
81+
?: all.firstOrNull { it.StableID == identifier }
82+
}
83+
84+
private fun showExitNodePicker() {
85+
navigateOverMain("exitNodes")
86+
}
87+
88+
private fun showSettings() {
89+
navigateOverMain("settings")
90+
}
91+
92+
private fun navigateOverMain(route: String) {
93+
navController.navigate(route) {
94+
popUpTo("main") { inclusive = false }
95+
launchSingleTop = true
96+
}
97+
}
98+
99+
private fun popToMain() {
100+
val popped = navController.popBackStack(route = "main", inclusive = false)
101+
if (!popped) {
102+
TSLog.d(TAG, "Deep link: popBackStack to 'main' returned false")
103+
}
104+
}
105+
}

scripts/deeplink-probe.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
// Command deeplink-probe fires a tailscale://navigate/<path> URI at an
5+
// attached device three ways (BROWSABLE implicit, bare implicit, explicit
6+
// component) and tails the relevant logcat lines so you can confirm
7+
// DeepLinkNavigator saw the intent.
8+
package main
9+
10+
import (
11+
"bufio"
12+
"flag"
13+
"fmt"
14+
"io"
15+
"os"
16+
"os/exec"
17+
"path/filepath"
18+
"strings"
19+
"time"
20+
)
21+
22+
const defaultPath = "main/devices"
23+
24+
func main() {
25+
flag.Usage = func() {
26+
fmt.Fprintf(os.Stderr, "Usage: %s [path-after-navigate]\n", filepath.Base(os.Args[0]))
27+
fmt.Fprintf(os.Stderr, " default path: %s\n", defaultPath)
28+
}
29+
flag.Parse()
30+
31+
tail := defaultPath
32+
if flag.NArg() >= 1 {
33+
tail = flag.Arg(0)
34+
}
35+
uri := "tailscale://navigate/" + strings.TrimPrefix(tail, "/")
36+
37+
logPath := filepath.Join(os.TempDir(), "deeplink-probe.log")
38+
logFile, err := os.Create(logPath)
39+
if err != nil {
40+
fmt.Fprintf(os.Stderr, "cannot create %s: %v\n", logPath, err)
41+
os.Exit(1)
42+
}
43+
defer logFile.Close()
44+
45+
tee := io.MultiWriter(os.Stdout, logFile)
46+
47+
fmt.Printf("URI: %s\nLogging to %s\n\n", uri, logPath)
48+
49+
if err := exec.Command("adb", "logcat", "-c").Run(); err != nil {
50+
fmt.Fprintf(os.Stderr, "adb logcat -c failed: %v\n", err)
51+
}
52+
53+
fires := []struct {
54+
label string
55+
shell string
56+
}{
57+
{"implicit (BROWSABLE)", "am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d '" + uri + "'"},
58+
{"implicit (no category)", "am start -W -a android.intent.action.VIEW -d '" + uri + "'"},
59+
{"explicit component", "am start -W -n com.tailscale.ipn/.MainActivity -a android.intent.action.VIEW -d '" + uri + "'"},
60+
}
61+
for _, f := range fires {
62+
fmt.Fprintf(tee, "=== %s ===\n", f.label)
63+
cmd := exec.Command("adb", "shell", f.shell)
64+
cmd.Stdout = tee
65+
cmd.Stderr = tee
66+
if err := cmd.Run(); err != nil {
67+
fmt.Fprintf(tee, "adb failed: %v\n", err)
68+
}
69+
fmt.Fprintln(tee)
70+
}
71+
72+
time.Sleep(time.Second)
73+
74+
fmt.Fprintln(tee, "\n=== logcat ===")
75+
logCmd := exec.Command("adb", "logcat", "-d", "-v", "brief")
76+
logCmd.Stderr = tee
77+
logOut, err := logCmd.StdoutPipe()
78+
if err != nil {
79+
fmt.Fprintf(tee, "adb logcat pipe failed: %v\n", err)
80+
} else if err := logCmd.Start(); err != nil {
81+
fmt.Fprintf(tee, "adb logcat failed to start: %v\n", err)
82+
} else {
83+
scanner := bufio.NewScanner(logOut)
84+
for scanner.Scan() {
85+
line := scanner.Text()
86+
if strings.Contains(line, "Main Activity") || strings.Contains(line, "DeepLinkNavigator") {
87+
fmt.Fprintln(tee, line)
88+
}
89+
}
90+
_ = logCmd.Wait()
91+
}
92+
93+
fmt.Printf("\nFull output: %s\n", logPath)
94+
}

0 commit comments

Comments
 (0)