android: prepare and connect VPN
-Create scope for IPNService, tied to the lifecycle of the VPN service -Create new IPNServiceManager, for actions taken by the backend -Call local.Start in the backend. This was missing before Updates tailscale/corp#18202 Signed-off-by: kari-ts <kari@tailscale.com>pull/208/head
parent
8b067ea102
commit
c92d347ac9
@ -1,143 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.util.Log;
|
||||
import android.os.Build;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.VpnService;
|
||||
import android.system.OsConstants;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.tailscale.ipn.ui.service.IpnManager;
|
||||
|
||||
public class IPNService extends VpnService {
|
||||
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
|
||||
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
|
||||
private IpnManager ipnManager;
|
||||
|
||||
@Override
|
||||
public void onCreate(){
|
||||
super.onCreate();
|
||||
ipnManager = new IpnManager();
|
||||
}
|
||||
|
||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
|
||||
((App)getApplicationContext()).autoConnect = false;
|
||||
close();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
|
||||
// Start VPN and connect to it due to Always-on VPN
|
||||
Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
|
||||
i.setPackage(getPackageName());
|
||||
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
|
||||
sendBroadcast(i);
|
||||
requestVPN();
|
||||
connect();
|
||||
return START_STICKY;
|
||||
}
|
||||
requestVPN();
|
||||
App app = ((App)getApplicationContext());
|
||||
if (app.vpnReady && app.autoConnect) {
|
||||
ipnManager.connect();
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void close() {
|
||||
stopForeground(true);
|
||||
disconnect();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
close();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override public void onRevoke() {
|
||||
close();
|
||||
super.onRevoke();
|
||||
}
|
||||
|
||||
private PendingIntent configIntent() {
|
||||
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
|
||||
private void disallowApp(VpnService.Builder b, String name) {
|
||||
try {
|
||||
b.addDisallowedApplication(name);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
protected VpnService.Builder newBuilder() {
|
||||
VpnService.Builder b = new VpnService.Builder()
|
||||
.setConfigureIntent(configIntent())
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
b.setMetered(false); // Inherit the metered status from the underlying networks.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
b.setUnderlyingNetworks(null); // Use all available networks.
|
||||
|
||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||
this.disallowApp(b, "com.google.android.apps.messaging");
|
||||
|
||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
||||
this.disallowApp(b, "com.google.stadia.android");
|
||||
|
||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||
this.disallowApp(b, "com.google.android.projection.gearhead");
|
||||
|
||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||
this.disallowApp(b, "com.gopro.smarty");
|
||||
|
||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||
this.disallowApp(b, "com.sonos.acr");
|
||||
this.disallowApp(b, "com.sonos.acr2");
|
||||
|
||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||
this.disallowApp(b, "com.google.android.apps.chromecast.app");
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
public void notify(String title, String message) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
public void updateStatusNotification(String title, String message) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW);
|
||||
|
||||
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
private native void requestVPN();
|
||||
|
||||
private native void disconnect();
|
||||
private native void connect();
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.net.VpnService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import android.util.Log
|
||||
import android.os.Build
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.system.OsConstants
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tailscale.ipn.ui.service.IpnServiceManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
class IPNService : VpnService() {
|
||||
private val vpnServiceJob = Job()
|
||||
private val vpnServiceScope = CoroutineScope(Dispatchers.Default + vpnServiceJob)
|
||||
|
||||
private var ipnServiceManager: IpnServiceManager? = null
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ipnServiceManager = IpnServiceManager(vpnServiceScope)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null && ACTION_STOP_VPN == intent.getAction()) {
|
||||
(getApplicationContext() as App).autoConnect = false
|
||||
close()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (intent != null && "android.net.VpnService" == intent.getAction()) {
|
||||
// Start VPN and connect to it due to Always-on VPN
|
||||
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
|
||||
i.setPackage(getPackageName())
|
||||
i.setClass(getApplicationContext(), IPNReceiver::class.java)
|
||||
sendBroadcast(i)
|
||||
requestVPN()
|
||||
ipnServiceManager?.connect()
|
||||
return START_STICKY
|
||||
}
|
||||
requestVPN()
|
||||
val app = applicationContext as App
|
||||
if (app.vpnReady && app.autoConnect) {
|
||||
ipnServiceManager?.connect()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
stopForeground(true)
|
||||
disconnect()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
close()
|
||||
vpnServiceJob.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
close()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
private fun configIntent(): PendingIntent {
|
||||
return PendingIntent.getActivity(this, 0, Intent(this, IPNActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
private fun disallowApp(b: Builder, name: String) {
|
||||
try {
|
||||
b.addDisallowedApplication(name)
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
protected fun newBuilder(): Builder {
|
||||
val b: Builder = Builder()
|
||||
.setConfigureIntent(configIntent())
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) b.setMetered(false) // Inherit the metered status from the underlying networks.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) b.setUnderlyingNetworks(null) // Use all available networks.
|
||||
|
||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||
disallowApp(b, "com.google.android.apps.messaging")
|
||||
|
||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
||||
disallowApp(b, "com.google.stadia.android")
|
||||
|
||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||
disallowApp(b, "com.google.android.projection.gearhead")
|
||||
|
||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||
disallowApp(b, "com.gopro.smarty")
|
||||
|
||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||
disallowApp(b, "com.sonos.acr")
|
||||
disallowApp(b, "com.sonos.acr2")
|
||||
|
||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||
disallowApp(b, "com.google.android.apps.chromecast.app")
|
||||
return b
|
||||
}
|
||||
|
||||
fun notify(title: String?, message: String?) {
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
fun updateStatusNotification(title: String?, message: String?) {
|
||||
val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
startForeground(App.STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private external fun requestVPN()
|
||||
private external fun disconnect()
|
||||
|
||||
companion object {
|
||||
const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"
|
||||
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.IPNReceiver
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.localapi.LocalApiClient
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
// Actions that can be taken by the backend
|
||||
interface IpnServiceActions {
|
||||
fun connect()
|
||||
}
|
||||
|
||||
class IpnServiceManager(scope: CoroutineScope) : IpnServiceActions {
|
||||
private var notifier = Notifier()
|
||||
|
||||
var apiClient = LocalApiClient(scope)
|
||||
var mdmSettings = MDMSettings()
|
||||
val model = IpnModel(notifier, apiClient, scope)
|
||||
|
||||
override fun connect() {
|
||||
val context = App.getApplication().applicationContext
|
||||
val callback: (com.tailscale.ipn.ui.localapi.Result<Ipn.Prefs>) -> Unit = { result ->
|
||||
if (result.successful) {
|
||||
val prefs = result.success
|
||||
Log.d("IpnManager","Connect: preferences updated successfully: $prefs")
|
||||
} else if (result.failed) {
|
||||
val error = result.error
|
||||
Log.d("IpnManager","Connect: failed to update preferences: ${error?.message}")
|
||||
}
|
||||
}
|
||||
model.setWantRunning(true, callback)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue