From 76b307f3e2e5703f6b61f80cd13ee2cb4dff8071 Mon Sep 17 00:00:00 2001 From: Brett Jenkins Date: Tue, 16 Jun 2026 14:25:47 +0100 Subject: [PATCH 1/2] android: add launcher shortcuts to connect and disconnect the VPN Add an AutomationActivity trampoline and two static launcher shortcuts (Connect, Disconnect) wired to the existing CONNECT_VPN / DISCONNECT_VPN intents. The actions can also be invoked from Samsung Modes and Routines or Tasker. The activity exists because starting an activity reliably wakes an app that the OS has force-stopped or deep-slept (e.g. Samsung battery management) and lets connect start the foreground VPN service, whereas the broadcast/Worker path is unreliable from that state. Exit-node automation is unchanged and remains available via the existing IPNReceiver USE_EXIT_NODE broadcast once the VPN is connected. Updates tailscale/tailscale#10831 Updates tailscale/tailscale#14148 Updates tailscale/tailscale#13623 Updates tailscale/tailscale#18847 Updates tailscale/tailscale#9531 Updates tailscale/tailscale#9497 Updates tailscale/tailscale#16415 Updates tailscale/tailscale#17855 Updates tailscale/tailscale#17738 Signed-off-by: Brett Jenkins --- android/src/main/AndroidManifest.xml | 24 +++++++ .../com/tailscale/ipn/AutomationActivity.kt | 63 +++++++++++++++++++ android/src/main/res/values/strings.xml | 6 ++ android/src/main/res/xml/shortcuts.xml | 28 +++++++++ 4 files changed, 121 insertions(+) create mode 100644 android/src/main/java/com/tailscale/ipn/AutomationActivity.kt create mode 100644 android/src/main/res/xml/shortcuts.xml diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 9633e2b2bd..872963a302 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -57,6 +57,30 @@ + + + + + + + + + + + connect() + IPNReceiver.INTENT_DISCONNECT_VPN -> UninitializedApp.get().stopVPN() + else -> TSLog.w(TAG, "unknown action: ${intent?.action}") + } + + // Never show any UI: finish before this activity becomes visible. + finish() + } + + /** + * Starts the VPN directly when it is ready and consent is already granted. Otherwise (not set up, + * or consent not yet granted) opens the app, which handles login and the consent prompt. + */ + private fun connect() { + val app = UninitializedApp.get() + if (app.isAbleToStartVPN() && VpnService.prepare(this) == null) { + app.startVPN() + } else { + launchMainActivity() + } + } + + private fun launchMainActivity() { + val intent = + Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + try { + startActivity(intent) + } catch (e: Exception) { + TSLog.e(TAG, "Failed to launch MainActivity: $e") + } + } + + companion object { + private const val TAG = "AutomationActivity" + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 608f1f2a2e..1e3f1fb664 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -378,4 +378,10 @@ Enable hardware attestation Use hardware-backed keys to bind node identity to the device + + Connect + Connect to Tailscale + Disconnect + Disconnect from Tailscale + diff --git a/android/src/main/res/xml/shortcuts.xml b/android/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000000..a4a1a3164f --- /dev/null +++ b/android/src/main/res/xml/shortcuts.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + From 469e51a44c97feb70ad2b8037c9cafb0485f0267 Mon Sep 17 00:00:00 2001 From: Brett Jenkins Date: Wed, 24 Jun 2026 18:23:55 +0100 Subject: [PATCH 2/2] android: lazily init app in localAPI Request to fix cold-start crash A localAPI request issued from a cold-started process (e.g. UseExitNodeWorker, kicked off by IPNReceiver) reads Request's lateinit `app` before the backend has been initialized, crashing the process with UninitializedPropertyAccessException. Resolve the app lazily via App.get() in execute(), matching the lazy init that Client already uses. Signed-off-by: Brett Jenkins --- .../java/com/tailscale/ipn/ui/localapi/Client.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index aeed568aca..b98cbbcf85 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -306,11 +306,24 @@ class Request( fun setApp(newApp: libtailscale.Application) { app = newApp } + + // Returns the libtailscale app, initializing the backend on demand. A localAPI request can be + // issued from a cold-started process (e.g. UseExitNodeWorker, kicked off by IPNReceiver) that + // never went through App.get(), so setApp() may not have run yet. Resolving lazily here mirrors + // Client's own lazy `app` and avoids crashing the process with an + // UninitializedPropertyAccessException. + private fun resolveApp(): libtailscale.Application { + if (!::app.isInitialized) { + app = App.get().getLibtailscaleApp() + } + return app + } } @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { + val app = resolveApp() TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { val resp =