diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
index 87ab33c023..5db2f65746 100644
--- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
+++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
@@ -13,6 +13,10 @@
import androidx.work.OutOfQuotaPolicy;
import androidx.work.WorkManager;
+import com.tailscale.ipn.ui.model.Ipn;
+import com.tailscale.ipn.ui.model.Netmap;
+import com.tailscale.ipn.ui.notifier.Notifier;
+
import java.util.Objects;
/**
@@ -46,6 +50,13 @@ public void onReceive(Context context, Intent intent) {
workManager.enqueueUniqueWork(WORK_CONNECT, ExistingWorkPolicy.REPLACE, req);
} else if (Objects.equals(action, INTENT_DISCONNECT_VPN)) {
+ // If we're already disconnected, skip triggering the worker to avoid overwriting the status notification
+ // with the "Stopping Tailscale VPN…" one.
+ boolean running = UninitializedApp.get().getAppScopedViewModel().getVpnActive().getValue();
+ if (!running) {
+ return;
+ }
+
OneTimeWorkRequest req =
new OneTimeWorkRequest.Builder(StopVPNWorker.class)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@@ -56,8 +67,23 @@ public void onReceive(Context context, Intent intent) {
} else if (Objects.equals(action, INTENT_USE_EXIT_NODE)) {
String exitNode = intent.getStringExtra("exitNode");
+ if (exitNode != null && exitNode.isEmpty()) exitNode = null;
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
+
+ Ipn.Prefs currentPrefs = Notifier.INSTANCE.getPrefs().getValue();
+ Netmap.NetworkMap currentNetmap = Notifier.INSTANCE.getNetmap().getValue();
+ String currentExitNodeName = UninitializedApp.Companion.getExitNodeName(currentPrefs, currentNetmap);
+ boolean currentAllowLan = false;
+ if (currentPrefs != null) {
+ currentAllowLan = currentPrefs.getExitNodeAllowLANAccess();
+ }
+ // If the exit node configuration is the same as requested, skip triggering the worker
+ // to avoid overwriting the status notification with the "Changing exit node…" one.
+ if (Objects.equals(exitNode, currentExitNodeName) && allowLanAccess == currentAllowLan) {
+ return;
+ }
+
Data input =
new Data.Builder()
.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode)
diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
index 9ab4183a5a..bd80760a6d 100644
--- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
+++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
@@ -3,6 +3,9 @@
package com.tailscale.ipn;
+import static com.tailscale.ipn.UninitializedApp.STATUS_NOTIFICATION_ID;
+
+import android.app.Application;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
@@ -12,6 +15,8 @@
import android.os.Build;
import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.ForegroundInfo;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
@@ -62,4 +67,20 @@ public Result doWork() {
return Result.failure();
}
+
+ @NonNull
+ @Override
+ public ForegroundInfo getForegroundInfo() {
+ // notification just so that there is no exception on android 11 and older (api 30 and older)
+ // it will be only briefly visible in the real world because the intent finishes almost instantly
+ // https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
+ Application app = UninitializedApp.get();
+ Notification notification = new NotificationCompat.Builder(app, UninitializedApp.STATUS_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.starting_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build();
+
+ return new ForegroundInfo(STATUS_NOTIFICATION_ID, notification);
+ }
}
diff --git a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
index 7bb2e172db..2fca971be4 100644
--- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
+++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
@@ -3,9 +3,15 @@
package com.tailscale.ipn;
+import static com.tailscale.ipn.UninitializedApp.STATUS_NOTIFICATION_ID;
+
+import android.app.Application;
+import android.app.Notification;
import android.content.Context;
import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.ForegroundInfo;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
@@ -26,4 +32,20 @@ public Result doWork() {
UninitializedApp.get().stopVPN();
return Result.success();
}
+
+ @NonNull
+ @Override
+ public ForegroundInfo getForegroundInfo() {
+ // notification just so that there is no exception on android 11 and older (api 30 and older)
+ // it will be only briefly visible in the real world because the intent finishes almost instantly
+ // https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
+ Application app = UninitializedApp.get();
+ Notification notification = new NotificationCompat.Builder(app, UninitializedApp.STATUS_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.stopping_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build();
+
+ return new ForegroundInfo(STATUS_NOTIFICATION_ID, notification);
+ }
}
diff --git a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
index e2b2bbc0d1..5c9432ec8c 100644
--- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
+++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
@@ -8,8 +8,10 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
+import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
+import com.tailscale.ipn.UninitializedApp.Companion.STATUS_NOTIFICATION_ID
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
@@ -104,6 +106,19 @@ class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
}
}
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ // notification just so that there is no exception on android 11 and older (api 30 and older)
+ // it will be only briefly visible in the real world because the intent finishes almost instantly
+ // https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
+ val app = UninitializedApp.get()
+ val notification =
+ NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.changing_exit_node_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build()
+ return ForegroundInfo(STATUS_NOTIFICATION_ID, notification)
+ }
companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 608f1f2a2e..a3904f2374 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -13,7 +13,7 @@
Not connected
%s
- Selected
+ Selected
Offline
OK
Continue
@@ -38,7 +38,7 @@
Acknowledgements
Privacy Policy
Terms of Service
- WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.
+ WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2026 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.
Managed by
@@ -142,11 +142,11 @@
As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to
-
+
and look for “Delete tailnet”.
-
+
All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist.
@@ -285,6 +285,9 @@
Multiple peers with name %1$s found
Peer with name %1$s is not an exit node
Use Exit Node Intent Failed
+ Starting Tailscale VPN…
+ Stopping Tailscale VPN…
+ Changing exit node…
Tailscale Connection Failed