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:
@@ -51,3 +51,6 @@ libtailscale-sources.jar
|
||||
.DS_Store
|
||||
|
||||
tailscale.version
|
||||
|
||||
# .tmp folder
|
||||
.tmp/
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user