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 {