Skip to content

Commit 1973375

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 1973375

5 files changed

Lines changed: 232 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)
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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
presentSettings()
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 -> {
49+
pushDeviceDetail(rest[0])
50+
true
51+
}
52+
else -> false
53+
}
54+
"exit-nodes" ->
55+
when {
56+
rest.size <= 1 || (rest.size == 2 && rest[0] == "location") -> {
57+
presentExitNodePicker()
58+
true
59+
}
60+
else -> false
61+
}
62+
else -> false
63+
}
64+
}
65+
66+
private fun showDeviceList() {
67+
popToMain()
68+
}
69+
70+
private fun pushDeviceDetail(identifier: String) {
71+
val node = findNode(identifier)
72+
if (node == null) {
73+
TSLog.d(TAG, "Deep link: device not found for '$identifier'")
74+
return
75+
}
76+
popToMain()
77+
navController.navigate("peerDetails/${node.StableID}")
78+
}
79+
80+
private fun findNode(identifier: String): Tailcfg.Node? {
81+
val netmap = Notifier.netmap.value ?: return null
82+
val all = netmap.Peers.orEmpty() + netmap.SelfNode
83+
return all.firstOrNull { identifier.equals(it.ComputedName, ignoreCase = true) }
84+
?: all.firstOrNull { it.StableID == identifier }
85+
}
86+
87+
private fun presentExitNodePicker() {
88+
popToMain()
89+
navController.navigate("exitNodes")
90+
}
91+
92+
private fun presentSettings() {
93+
popToMain()
94+
navController.navigate("settings")
95+
}
96+
97+
private fun popToMain() {
98+
navController.popBackStack(route = "main", inclusive = false)
99+
}
100+
}

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)