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 {