diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index 2d3ba020f3..01bf11b93a 100644
--- a/android/src/main/java/com/tailscale/ipn/App.kt
+++ b/android/src/main/java/com/tailscale/ipn/App.kt
@@ -117,6 +117,19 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.vpn_status),
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
NotificationManagerCompat.IMPORTANCE_MIN)
+ createNotificationChannel(
+ STATUS_FAILURE_CHANNEL_ID,
+ getString(R.string.intent_failure_channel_name),
+ getString(R.string.intent_failure_channel_description),
+ NotificationManagerCompat.IMPORTANCE_MIN)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ createNotificationChannel(
+ WORKER_LEGACY_CHANNEL_ID,
+ getString(R.string.intent_status_channel_name),
+ getString(R.string.intent_status_channel_description),
+ NotificationManagerCompat.IMPORTANCE_MIN
+ )
+ }
createNotificationChannel(
FILE_CHANNEL_ID,
getString(R.string.taildrop_file_transfers),
@@ -507,7 +520,10 @@ open class UninitializedApp : Application() {
const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
+ const val STATUS_WORKER_LEGACY_NOTIFICATION_ID = 3
const val STATUS_CHANNEL_ID = "tailscale-status"
+ const val STATUS_FAILURE_CHANNEL_ID = "tailscale-status-failure"
+ const val WORKER_LEGACY_CHANNEL_ID = "tailscale-worker-legacy"
// Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
@@ -641,7 +657,7 @@ open class UninitializedApp : Application() {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
}
- fun notifyStatus(notification: Notification) {
+ fun notifyStatus(notification: Notification, exitNodeFailure: Boolean = false) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
@@ -653,7 +669,10 @@ open class UninitializedApp : Application() {
// for ActivityCompat#requestPermissions for more details.
return
}
- notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
+ notificationManager.notify(
+ if (exitNodeFailure) STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID else STATUS_NOTIFICATION_ID,
+ notification
+ )
}
fun buildStatusNotification(
diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java
index 4d60c5c440..58b20709a6 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;
/**
@@ -47,6 +51,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)
@@ -57,8 +68,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..b44bca4301 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.WORKER_LEGACY_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.starting_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build();
+
+ return new ForegroundInfo(UninitializedApp.STATUS_WORKER_LEGACY_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..895ffbe465 100644
--- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
+++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
@@ -3,9 +3,17 @@
package com.tailscale.ipn;
+import static com.tailscale.ipn.UninitializedApp.STATUS_NOTIFICATION_ID;
+import static com.tailscale.ipn.UninitializedApp.STATUS_WORKER_LEGACY_NOTIFICATION_ID;
+
+import android.app.Application;
+import android.app.Notification;
+import android.app.NotificationManager;
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 +34,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.WORKER_LEGACY_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.stopping_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build();
+
+ return new ForegroundInfo(STATUS_WORKER_LEGACY_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..9aa80a625d 100644
--- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
+++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
@@ -8,105 +8,161 @@ 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_FAILURE_CHANNEL_ID
+import com.tailscale.ipn.UninitializedApp.Companion.STATUS_WORKER_LEGACY_NOTIFICATION_ID
+import com.tailscale.ipn.UninitializedApp.Companion.WORKER_LEGACY_CHANNEL_ID
+import com.tailscale.ipn.UninitializedApp.Companion.get
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
+import kotlin.coroutines.resume
class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
- override suspend fun doWork(): Result {
- val app = UninitializedApp.get()
- suspend fun runAndGetResult(): String? {
- val exitNodeName = inputData.getString(EXIT_NODE_NAME)
-
- val exitNodeId =
- if (exitNodeName.isNullOrEmpty()) {
- null
- } else {
- if (!app.isAbleToStartVPN()) {
- return app.getString(R.string.vpn_is_not_ready_to_start)
- }
+ override suspend fun doWork(): Result {
+ val app = get()
+
+ val exitNodeName = inputData.getString(EXIT_NODE_NAME)
- val peers =
- (Notifier.netmap.value
- ?: run {
- return@runAndGetResult app.getString(R.string.tailscale_is_not_setup)
- })
- .Peers
- ?: run {
- return@runAndGetResult app.getString(R.string.no_peers_found)
+ val exitNodeId =
+ if (exitNodeName.isNullOrEmpty()) {
+ null
+ } else {
+ if (!app.isAbleToStartVPN()) {
+ return failure(
+ app,
+ app.getString(R.string.vpn_is_not_ready_to_start),
+ )
+ }
+
+ val netmap =
+ Notifier.netmap.value
+ ?: return failure(
+ app,
+ app.getString(R.string.tailscale_is_not_setup),
+ )
+
+ val peers =
+ netmap.Peers
+ ?: return failure(
+ app,
+ app.getString(R.string.no_peers_found),
+ )
+
+
+ val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
+
+ when {
+ filteredPeers.isEmpty() -> {
+ return failure(
+ app,
+ app.getString(
+ R.string.no_peers_with_name_found,
+ exitNodeName,
+ ),
+ )
}
- val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
+ filteredPeers.size > 1 -> {
+ return failure(
+ app,
+ app.getString(
+ R.string.multiple_peers_with_name_found,
+ exitNodeName,
+ ),
+ )
+ }
- if (filteredPeers.isEmpty()) {
- return app.getString(R.string.no_peers_with_name_found, exitNodeName)
- } else if (filteredPeers.size > 1) {
- return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
- } else if (!filteredPeers[0].isExitNode) {
- return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName)
+ !filteredPeers[0].isExitNode -> {
+ return failure(
+ app,
+ app.getString(
+ R.string.peer_with_name_is_not_an_exit_node,
+ exitNodeName,
+ ),
+ )
+ }
+ }
+ filteredPeers[0].StableID
}
- filteredPeers[0].StableID
- }
+ val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
+ val prefsOut = Ipn.MaskedPrefs()
+ prefsOut.ExitNodeID = exitNodeId
+ prefsOut.ExitNodeAllowLANAccess = allowLanAccess
+
+ val scope = CoroutineScope(kotlinx.coroutines.currentCoroutineContext())
+
+ val result: String? =
+ kotlinx.coroutines.suspendCancellableCoroutine { cont ->
+ Client(scope).editPrefs(prefsOut) { editResult ->
+ val err =
+ if (editResult.isFailure) {
+ editResult.exceptionOrNull()?.message
+ } else {
+ null
+ }
+ if (cont.isActive) {
+ cont.resume(err)
+ }
+ }
+ }
- val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
- val prefsOut = Ipn.MaskedPrefs()
- prefsOut.ExitNodeID = exitNodeId
- prefsOut.ExitNodeAllowLANAccess = allowLanAccess
+ return if (result != null) {
+ failure(app, result)
+ } else {
+ Result.success()
+ }
+ }
- val scope = CoroutineScope(Dispatchers.Default + Job())
- var result: String? = null
- Client(scope).editPrefs(prefsOut) {
- result =
- if (it.isFailure) {
- it.exceptionOrNull()?.message
- } else {
- null
+ private fun failure(
+ app: UninitializedApp,
+ result: String,
+ ): Result {
+ val intent =
+ Intent(app, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
- }
- scope.coroutineContext[Job]?.join()
-
- return result
+ val pendingIntent: PendingIntent =
+ PendingIntent.getActivity(
+ app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification =
+ NotificationCompat.Builder(app, STATUS_FAILURE_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
+ .setContentText(result)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .setSilent(true)
+ .build()
+
+ app.notifyStatus(notification, true)
+ return Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
}
- val result = runAndGetResult()
-
- return if (result != null) {
- val intent =
- Intent(app, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- }
- val pendingIntent: PendingIntent =
- PendingIntent.getActivity(
- app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
-
- val notification =
- NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_notification)
- .setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
- .setContentText(result)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
- .setContentIntent(pendingIntent)
- .build()
-
- app.notifyStatus(notification)
-
- Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
- } else {
- Result.success()
+ 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, WORKER_LEGACY_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(app.getString(R.string.changing_exit_node_notification))
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build()
+ return ForegroundInfo(STATUS_WORKER_LEGACY_NOTIFICATION_ID, notification)
}
- }
- companion object {
- const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
- const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
- const val ERROR_KEY = "error"
- }
+ companion object {
+ const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
+ const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
+ const val ERROR_KEY = "error"
+ }
}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 10804d88a0..867db2dbb1 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.
@@ -287,6 +287,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
@@ -295,6 +298,10 @@
Taildrop file transfers
VPN status
+ Intents failures
+ Notifications about failures when managing VPN from Intents (3rd party integration)
+ Intents status
+ Notifications about a status when managing VPN from Intents (3rd party integration) on Android 11 and older (You can disable this channel)
VPN start
Notifications delivered when user interaction is required to establish the VPN tunnel.
Optional notifications which display the status of the VPN tunnel.