From 179e75bd20a8372e0f422da832788a2be91087d3 Mon Sep 17 00:00:00 2001
From: Pawloland <59684145+Pawloland@users.noreply.github.com>
Date: Mon, 18 May 2026 13:32:00 +0200
Subject: [PATCH 1/3] Fix expedited WorkRequests by implementing
getForegroundInfo() method for all Workers, thus fixing intent handling on
Android 11 and older Fixes tailscale/tailscale#19772
Signed-off-by: Pawloland <59684145+Pawloland@users.noreply.github.com>
---
.../java/com/tailscale/ipn/IPNReceiver.java | 26 +++++++++++++++++++
.../com/tailscale/ipn/StartVPNWorker.java | 21 +++++++++++++++
.../java/com/tailscale/ipn/StopVPNWorker.java | 22 ++++++++++++++++
.../com/tailscale/ipn/UseExitNodeWorker.kt | 15 +++++++++++
android/src/main/res/values/strings.xml | 11 +++++---
5 files changed, 91 insertions(+), 4 deletions(-)
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
From 47c06216612f0c11868767b98a815647395a80a9 Mon Sep 17 00:00:00 2001
From: Pawloland <59684145+Pawloland@users.noreply.github.com>
Date: Wed, 20 May 2026 16:39:15 +0200
Subject: [PATCH 2/3] Edit UseExitNodeWorker to fix foreground notification
becoming non-dismissible on Android 11 and older when triggering
USE_EXIT_NODE intent
Signed-off-by: Pawloland <59684145+Pawloland@users.noreply.github.com>
---
.../com/tailscale/ipn/UseExitNodeWorker.kt | 122 ++++++++++--------
1 file changed, 69 insertions(+), 53 deletions(-)
diff --git a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
index 5c9432ec8c..0fa8de0dfa 100644
--- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
+++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
@@ -16,69 +16,85 @@ 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 = UninitializedApp.get()
- 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 exitNodeName = inputData.getString(EXIT_NODE_NAME)
+
+ val exitNodeId =
+ if (exitNodeName.isNullOrEmpty()) {
+ null
+ } else {
+ if (!app.isAbleToStartVPN()) {
+ return Result.failure(
+ Data.Builder().putString(
+ ERROR_KEY, app.getString(R.string.vpn_is_not_ready_to_start)
+ ).build()
+ )
+ }
+
+ val netmap = Notifier.netmap.value ?: return Result.failure(
+ Data.Builder().putString(
+ ERROR_KEY, app.getString(R.string.tailscale_is_not_setup)
+ ).build()
+ )
+
+ val peers = netmap.Peers ?: return Result.failure(
+ Data.Builder().putString(
+ ERROR_KEY, app.getString(R.string.no_peers_found)
+ ).build()
+ )
+
+
+ val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
+
+ when {
+ filteredPeers.isEmpty() -> {
+ return Result.failure(
+ Data.Builder().putString(ERROR_KEY, app.getString(R.string.no_peers_with_name_found, exitNodeName)).build()
+ )
}
- val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
+ filteredPeers.size > 1 -> {
+ return Result.failure(
+ Data.Builder().putString(ERROR_KEY, app.getString(R.string.multiple_peers_with_name_found, exitNodeName)).build()
+ )
+ }
- 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 Result.failure(
+ Data.Builder().putString(ERROR_KEY, app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName)).build()
+ )
+ }
+ }
+ 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(Dispatchers.Default + Job())
- var result: String? = null
- Client(scope).editPrefs(prefsOut) {
- result =
- if (it.isFailure) {
- it.exceptionOrNull()?.message
- } else {
- null
+ 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)
+ }
+ }
}
- }
-
- scope.coroutineContext[Job]?.join()
-
- return result
- }
-
- val result = runAndGetResult()
return if (result != null) {
val intent =
From a9b74feba4197e436950ec98c38b17b58387978a Mon Sep 17 00:00:00 2001
From: Pawloland <59684145+Pawloland@users.noreply.github.com>
Date: Mon, 27 Jul 2026 22:54:39 +0200
Subject: [PATCH 3/3] Add separate channels for errors
Errors reported in Workers are now in separate channels using separate ID, so they can be disabled in system settings, while keeping the main status notifications intact (as the state doesn't change when error occurs).
Also add separate channel for notifications that are visible when starting an intent from 3rd party integrations or shell, on devices older than Android 12. They also can be disabled in settings, so the app will behave like in newer Android versions while not overriding main status notification.
Signed-off-by: Pawloland <59684145+Pawloland@users.noreply.github.com>
---
.../src/main/java/com/tailscale/ipn/App.kt | 23 ++-
.../com/tailscale/ipn/StartVPNWorker.java | 4 +-
.../java/com/tailscale/ipn/StopVPNWorker.java | 6 +-
.../com/tailscale/ipn/UseExitNodeWorker.kt | 133 +++++++++++-------
android/src/main/res/values/strings.xml | 4 +
5 files changed, 110 insertions(+), 60 deletions(-)
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/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
index bd80760a6d..b44bca4301 100644
--- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
+++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java
@@ -75,12 +75,12 @@ public ForegroundInfo getForegroundInfo() {
// 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)
+ 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(STATUS_NOTIFICATION_ID, notification);
+ 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 2fca971be4..895ffbe465 100644
--- a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
+++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java
@@ -4,9 +4,11 @@
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;
@@ -40,12 +42,12 @@ public ForegroundInfo getForegroundInfo() {
// 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)
+ 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_NOTIFICATION_ID, notification);
+ 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 0fa8de0dfa..9aa80a625d 100644
--- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
+++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
@@ -10,8 +10,10 @@ 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.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
@@ -21,7 +23,7 @@ import kotlin.coroutines.resume
class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
- val app = UninitializedApp.get()
+ val app = get()
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
@@ -30,44 +32,57 @@ class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
null
} else {
if (!app.isAbleToStartVPN()) {
- return Result.failure(
- Data.Builder().putString(
- ERROR_KEY, app.getString(R.string.vpn_is_not_ready_to_start)
- ).build()
+ return failure(
+ app,
+ app.getString(R.string.vpn_is_not_ready_to_start),
)
}
- val netmap = Notifier.netmap.value ?: return Result.failure(
- Data.Builder().putString(
- ERROR_KEY, app.getString(R.string.tailscale_is_not_setup)
- ).build()
- )
+ val netmap =
+ Notifier.netmap.value
+ ?: return failure(
+ app,
+ app.getString(R.string.tailscale_is_not_setup),
+ )
- val peers = netmap.Peers ?: return Result.failure(
- Data.Builder().putString(
- ERROR_KEY, app.getString(R.string.no_peers_found)
- ).build()
- )
+ 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 Result.failure(
- Data.Builder().putString(ERROR_KEY, app.getString(R.string.no_peers_with_name_found, exitNodeName)).build()
+ return failure(
+ app,
+ app.getString(
+ R.string.no_peers_with_name_found,
+ exitNodeName,
+ ),
)
}
filteredPeers.size > 1 -> {
- return Result.failure(
- Data.Builder().putString(ERROR_KEY, app.getString(R.string.multiple_peers_with_name_found, exitNodeName)).build()
+ return failure(
+ app,
+ app.getString(
+ R.string.multiple_peers_with_name_found,
+ exitNodeName,
+ ),
)
}
!filteredPeers[0].isExitNode -> {
- return Result.failure(
- Data.Builder().putString(ERROR_KEY, app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName)).build()
+ return failure(
+ app,
+ app.getString(
+ R.string.peer_with_name_is_not_an_exit_node,
+ exitNodeName,
+ ),
)
}
}
@@ -96,31 +111,40 @@ class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
}
}
- 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()
+ return if (result != null) {
+ failure(app, result)
+ } else {
+ Result.success()
+ }
+ }
+
+ 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
+ }
+
+ 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())
}
- }
override suspend fun getForegroundInfo(): ForegroundInfo {
// notification just so that there is no exception on android 11 and older (api 30 and older)
@@ -128,16 +152,17 @@ class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
// 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)
+ 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_NOTIFICATION_ID, notification)
+ 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 fc8b66dbe9..867db2dbb1 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -298,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.