From eb9599540c228ce4865f51539996882f172e69f1 Mon Sep 17 00:00:00 2001 From: Brett Jenkins Date: Mon, 17 Apr 2023 20:05:47 +0100 Subject: [PATCH] Add intents (#87) IPNReceiver: Add intents to connect and disconnect VPN Added a new class IPNReceiver to listen to intents silently and connect and disconnect the VPN. This uses workers to avoid doing too much in the IPNReceiver which is to be avoided according to documentation. Also includes a fix for vpn occasionally not starting. Think this was due to a race condition, but now only sets autoConnect to false when we know a connection is connecting or connected. Fixes https://github.com/tailscale/tailscale/issues/3547 Updates https://github.com/tailscale/tailscale/issues/2481 Signed-off-by: Brett Jenkins --- android/build.gradle | 3 +- android/src/main/AndroidManifest.xml | 8 +++ .../src/main/java/com/tailscale/ipn/App.java | 14 +++- .../java/com/tailscale/ipn/IPNReceiver.java | 26 ++++++++ .../java/com/tailscale/ipn/IPNService.java | 6 ++ .../com/tailscale/ipn/StartVPNWorker.java | 65 +++++++++++++++++++ .../java/com/tailscale/ipn/StopVPNWorker.java | 25 +++++++ cmd/tailscale/callbacks.go | 15 +++++ 8 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/IPNReceiver.java create mode 100644 android/src/main/java/com/tailscale/ipn/StartVPNWorker.java create mode 100644 android/src/main/java/com/tailscale/ipn/StopVPNWorker.java diff --git a/android/build.gradle b/android/build.gradle index 49424c0..66ed51e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,7 +22,7 @@ apply plugin: 'com.android.application' android { ndkVersion "23.1.7779620" - compileSdkVersion 30 + compileSdkVersion 31 defaultConfig { minSdkVersion 22 targetSdkVersion 31 @@ -49,6 +49,7 @@ dependencies { implementation "androidx.core:core:1.2.0" implementation "androidx.browser:browser:1.2.0" implementation "androidx.security:security-crypto:1.1.0-alpha03" + implementation "androidx.work:work-runtime:2.7.0" implementation ':ipn@aar' testCompile "junit:junit:4.12" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 8dfe5f1..f47abd8 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -55,6 +55,14 @@ + + + + + + diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index efb9be9..98b6cbf 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -106,6 +106,9 @@ public class App extends Application { NetworkInfo active = cMgr.getActiveNetworkInfo(); // https://developer.android.com/training/monitoring-device-state/connectivity-status-type boolean isConnected = active != null && active.isConnectedOrConnecting(); + if (isConnected) { + ((App)getApplicationContext()).autoConnect = false; + } onConnectivityChanged(isConnected); } @@ -161,11 +164,20 @@ public class App extends Application { ); } + public boolean autoConnect = false; + public boolean vpnReady = false; + void setTileReady(boolean ready) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return; } QuickToggleService.setReady(this, ready); + android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect); + + vpnReady = ready; + if (ready && autoConnect) { + startVPN(); + } } void setTileStatus(boolean status) { @@ -333,7 +345,7 @@ public class App extends Application { nm.notify(FILE_NOTIFICATION_ID, builder.build()); } - private void createNotificationChannel(String id, String name, int importance) { + public void createNotificationChannel(String id, String name, int importance) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java new file mode 100644 index 0000000..a0f7f32 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java @@ -0,0 +1,26 @@ +// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import androidx.work.WorkManager; +import androidx.work.OneTimeWorkRequest; + +public class IPNReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + WorkManager workManager = WorkManager.getInstance(context); + + // On the relevant action, start the relevant worker, which can stay active for longer than this receiver can. + if (intent.getAction() == "com.tailscale.ipn.CONNECT_VPN") { + workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build()); + } else if (intent.getAction() == "com.tailscale.ipn.DISCONNECT_VPN") { + workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build()); + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java index 10fbc20..a8458f0 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.java +++ b/android/src/main/java/com/tailscale/ipn/IPNService.java @@ -26,6 +26,10 @@ public class IPNService extends VpnService { return START_NOT_STICKY; } connect(); + App app = ((App)getApplicationContext()); + if (app.vpnReady && app.autoConnect) { + directConnect(); + } return START_STICKY; } @@ -116,4 +120,6 @@ public class IPNService extends VpnService { private native void connect(); private native void disconnect(); + + public native void directConnect(); } diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java new file mode 100644 index 0000000..8fbe15f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java @@ -0,0 +1,65 @@ +// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn; + +import androidx.work.Worker; +import android.content.Context; +import androidx.work.WorkerParameters; +import android.net.VpnService; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; + +public final class StartVPNWorker extends Worker { + + public StartVPNWorker( + Context appContext, + WorkerParameters workerParams) { + super(appContext, workerParams); + } + + @Override public Result doWork() { + // We will start the VPN from the background + App app = ((App)getApplicationContext()); + app.autoConnect = true; + // We need to make sure we prepare the VPN Service, just in case it isn't prepared. + + Intent intent = VpnService.prepare(app); + if (intent == null) { + // If null then the VPN is already prepared and/or it's just been prepared because we have permission + app.startVPN(); + return Result.success(); + } else { + // This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided. + android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided."); + + // Send notification + NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); + String channelId = "start_vpn_channel"; + + // Use createNotificationChannel method from App.java + app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT); + + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags); + + Notification notification = new Notification.Builder(app, channelId) + .setContentTitle("Tailscale Connection Failed") + .setContentText("Tap here to renew permission.") + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build(); + + notificationManager.notify(1, notification); + + return Result.failure(); + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java new file mode 100644 index 0000000..296ce28 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/StopVPNWorker.java @@ -0,0 +1,25 @@ +// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn; + +import androidx.work.Worker; +import android.content.Context; +import androidx.work.WorkerParameters; + +public final class StopVPNWorker extends Worker { + + public StopVPNWorker( + Context appContext, + WorkerParameters workerParams) { + super(appContext, workerParams); + } + + @Override public Result doWork() { + disconnect(); + return Result.success(); + } + + private native void disconnect(); +} diff --git a/cmd/tailscale/callbacks.go b/cmd/tailscale/callbacks.go index 0895a99..534e439 100644 --- a/cmd/tailscale/callbacks.go +++ b/cmd/tailscale/callbacks.go @@ -96,12 +96,27 @@ func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) { onConnect <- jni.NewGlobalRef(jenv, jni.Object(this)) } +//export Java_com_tailscale_ipn_IPNService_directConnect +func Java_com_tailscale_ipn_IPNService_directConnect(env *C.JNIEnv, this C.jobject) { + requestBackend(ConnectEvent{Enable: true}) +} + //export Java_com_tailscale_ipn_IPNService_disconnect func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) { jenv := (*jni.Env)(unsafe.Pointer(env)) onDisconnect <- jni.NewGlobalRef(jenv, jni.Object(this)) } +//export Java_com_tailscale_ipn_StartVPNWorker_connect +func Java_com_tailscale_ipn_StartVPNWorker_connect(env *C.JNIEnv, this C.jobject) { + requestBackend(ConnectEvent{Enable: true}) +} + +//export Java_com_tailscale_ipn_StopVPNWorker_disconnect +func Java_com_tailscale_ipn_StopVPNWorker_disconnect(env *C.JNIEnv, this C.jobject) { + requestBackend(ConnectEvent{Enable: false}) +} + //export Java_com_tailscale_ipn_App_onConnectivityChanged func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls C.jclass, connected C.jboolean) { select {