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>
This commit is contained in:
Will Hannah
2026-07-02 16:09:45 -04:00
parent d53001c453
commit 7af84d0bc6
5 changed files with 244 additions and 0 deletions
+3
View File
@@ -51,3 +51,6 @@ libtailscale-sources.jar
.DS_Store
tailscale.version
# .tmp folder
.tmp/
+6
View File
@@ -57,6 +57,12 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tailscale" android:host="navigate" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
@@ -62,6 +62,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.DeepLinkNavigator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.AboutView
@@ -114,6 +115,8 @@ class MainActivity : ComponentActivity() {
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private lateinit var appViewModel: AppViewModel
private lateinit var viewModel: MainViewModel
private var deepLinkNavigator: DeepLinkNavigator? = null
private var pendingDeepLink: Uri? = null
val permissionsViewModel: PermissionsViewModel by viewModels()
@@ -136,6 +139,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingDeepLink = deepLinkUri(intent)
// grab app to make sure it initializes
App.get()
appViewModel = (application as App).getAppScopedViewModel()
@@ -253,6 +258,20 @@ class MainActivity : ComponentActivity() {
navController = rememberNavController()
val introNotYetViewed = remember { isIntroScreenViewedSet() }
LaunchedEffect(navController) {
val nav = DeepLinkNavigator(navController)
deepLinkNavigator = nav
pendingDeepLink?.let {
pendingDeepLink = null
when {
introNotYetViewed -> TSLog.d(TAG, "Deep link dropped, intro not viewed: $it")
!nav.handle(it) -> TSLog.d(TAG, "Deep link handler returned false for $it")
}
}
}
AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
@@ -483,6 +502,23 @@ class MainActivity : ComponentActivity() {
}
}
}
deepLinkUri(intent)?.let { uri ->
val nav = deepLinkNavigator
if (nav != null) {
if (!nav.handle(uri)) {
TSLog.d(TAG, "Deep link handler returned false for $uri")
}
} else {
pendingDeepLink = uri
}
}
}
private fun deepLinkUri(intent: Intent?): Uri? {
if (intent?.action != Intent.ACTION_VIEW) return null
val uri = intent.data ?: return null
if (uri.scheme != "tailscale" || uri.host != "navigate") return null
return uri
}
private fun login(urlString: String) {
@@ -0,0 +1,105 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.net.Uri
import androidx.navigation.NavHostController
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
// Deep links must only navigate — never perform an action on the user's
// behalf. Tails whose leaf would imply an action (e.g., a specific exit
// node) are accepted for URL compatibility but resolve to the parent
// view; the user picks and confirms in-app.
class DeepLinkNavigator(private val navController: NavHostController) {
companion object {
private const val TAG = "DeepLinkNavigator"
}
fun handle(uri: Uri): Boolean {
if (uri.host != "navigate") return false
val segments = uri.pathSegments
val window = segments.firstOrNull() ?: return false
val tail = segments.drop(1)
return when (window) {
"main" -> handleMain(tail)
"settings" -> {
showSettings()
true
}
else -> false
}
}
private fun handleMain(tail: List<String>): Boolean {
val tab = tail.firstOrNull() ?: return false
val rest = tail.drop(1)
return when (tab) {
"devices" ->
when (rest.size) {
0 -> {
showDeviceList()
true
}
1 -> pushDeviceDetail(rest[0])
else -> false
}
"exit-nodes" ->
when {
rest.size <= 1 || (rest.size == 2 && rest[0] == "location") -> {
showExitNodePicker()
true
}
else -> false
}
else -> false
}
}
private fun showDeviceList() {
popToMain()
}
private fun pushDeviceDetail(identifier: String): Boolean {
val node = findNode(identifier)
if (node == null) {
TSLog.d(TAG, "Deep link: device not found for '$identifier'")
return false
}
navigateOverMain("peerDetails/${node.StableID}")
return true
}
private fun findNode(identifier: String): Tailcfg.Node? {
val netmap = Notifier.netmap.value ?: return null
val all = netmap.Peers.orEmpty() + netmap.SelfNode
return all.firstOrNull { identifier.equals(it.ComputedName, ignoreCase = true) }
?: all.firstOrNull { it.StableID == identifier }
}
private fun showExitNodePicker() {
navigateOverMain("exitNodes")
}
private fun showSettings() {
navigateOverMain("settings")
}
private fun navigateOverMain(route: String) {
navController.navigate(route) {
popUpTo("main") { inclusive = false }
launchSingleTop = true
}
}
private fun popToMain() {
val popped = navController.popBackStack(route = "main", inclusive = false)
if (!popped) {
TSLog.d(TAG, "Deep link: popBackStack to 'main' returned false")
}
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Command deeplink-probe fires a tailscale://navigate/<path> URI at an
// attached device three ways (BROWSABLE implicit, bare implicit, explicit
// component) and tails the relevant logcat lines so you can confirm
// DeepLinkNavigator saw the intent.
package main
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const defaultPath = "main/devices"
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [path-after-navigate]\n", filepath.Base(os.Args[0]))
fmt.Fprintf(os.Stderr, " default path: %s\n", defaultPath)
}
flag.Parse()
tail := defaultPath
if flag.NArg() >= 1 {
tail = flag.Arg(0)
}
uri := "tailscale://navigate/" + strings.TrimPrefix(tail, "/")
logPath := filepath.Join(os.TempDir(), "deeplink-probe.log")
logFile, err := os.Create(logPath)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot create %s: %v\n", logPath, err)
os.Exit(1)
}
defer logFile.Close()
tee := io.MultiWriter(os.Stdout, logFile)
fmt.Printf("URI: %s\nLogging to %s\n\n", uri, logPath)
if err := exec.Command("adb", "logcat", "-c").Run(); err != nil {
fmt.Fprintf(os.Stderr, "adb logcat -c failed: %v\n", err)
}
fires := []struct {
label string
shell string
}{
{"implicit (BROWSABLE)", "am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d '" + uri + "'"},
{"implicit (no category)", "am start -W -a android.intent.action.VIEW -d '" + uri + "'"},
{"explicit component", "am start -W -n com.tailscale.ipn/.MainActivity -a android.intent.action.VIEW -d '" + uri + "'"},
}
for _, f := range fires {
fmt.Fprintf(tee, "=== %s ===\n", f.label)
cmd := exec.Command("adb", "shell", f.shell)
cmd.Stdout = tee
cmd.Stderr = tee
if err := cmd.Run(); err != nil {
fmt.Fprintf(tee, "adb failed: %v\n", err)
}
fmt.Fprintln(tee)
}
time.Sleep(time.Second)
fmt.Fprintln(tee, "\n=== logcat ===")
logCmd := exec.Command("adb", "logcat", "-d", "-v", "brief")
logCmd.Stderr = tee
logOut, err := logCmd.StdoutPipe()
if err != nil {
fmt.Fprintf(tee, "adb logcat pipe failed: %v\n", err)
} else if err := logCmd.Start(); err != nil {
fmt.Fprintf(tee, "adb logcat failed to start: %v\n", err)
} else {
scanner := bufio.NewScanner(logOut)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "Main Activity") || strings.Contains(line, "DeepLinkNavigator") {
fmt.Fprintln(tee, line)
}
}
_ = logCmd.Wait()
}
fmt.Printf("\nFull output: %s\n", logPath)
}