diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index bc0128fa43..bde7ad9443 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -90,6 +90,7 @@ + diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 9248f20d88..8f37e473a2 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -335,6 +335,7 @@ class App : UninitializedApp(), libtailscale.AppContext { open class UninitializedApp : Application() { companion object { const val STATUS_NOTIFICATION_ID = 1 + const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_CHANNEL_ID = "tailscale-status" // Key for shared preference that tracks whether or not we're able to start @@ -388,6 +389,10 @@ open class UninitializedApp : Application() { } fun notifyStatus(vpnRunning: Boolean) { + notifyStatus(buildStatusNotification(vpnRunning)) + } + + fun notifyStatus(notification: Notification) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { // TODO: Consider calling @@ -399,7 +404,7 @@ open class UninitializedApp : Application() { // for ActivityCompat#requestPermissions for more details. return } - notificationManager.notify(STATUS_NOTIFICATION_ID, buildStatusNotification(vpnRunning)) + notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } fun buildStatusNotification(vpnRunning: Boolean): Notification { diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java index af5c3f1dcc..ea0ed92701 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java +++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java @@ -6,6 +6,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import androidx.work.Data; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkManager; @@ -20,6 +21,8 @@ public class IPNReceiver extends BroadcastReceiver { public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN"; + private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE"; + @Override public void onReceive(Context context, Intent intent) { WorkManager workManager = WorkManager.getInstance(context); @@ -30,5 +33,13 @@ public void onReceive(Context context, Intent intent) { } else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) { workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build()); } + else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) { + String exitNode = intent.getStringExtra("exitNode"); + boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false); + Data.Builder workData = new Data.Builder(); + workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode); + workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess); + workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build()); + } } } diff --git a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt new file mode 100644 index 0000000000..ff48dc3530 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt @@ -0,0 +1,112 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters +import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID +import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID +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 + +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) + } + + 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 filteredPeers = peers.filter { + it.displayName == exitNodeName + }.toList() + + 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].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 + } + } + + scope.coroutineContext[Job]?.join() + + return result + } + + 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() + } + } + + 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 54587e63ac..701f9c6389 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -243,6 +243,15 @@ All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network. Scan this QR code to log in to your tailnet + + VPN is not ready to start + Tailscale is not setup + No peers found + No peers with name %1$s found + Multiple peers with name %1$s found + Peer with name %1$s is not an exit node + Use Exit Node Intent Failed + Tailscale Connection Failed Tap here to open Tailscale.