From 05bcd0f55111746af642be1d8d2321a8f043ba9d 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 97ecc92ba4..c7c5add0ae 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -63,6 +63,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 5f96418590..51b6754f6f 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -400,4 +400,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 e48273c60bac803f8e0d46404f0b8d84ad40a4a4 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 f12fba3273..4b22056305 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 @@ -313,11 +313,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 =