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>
kari/connect
kari-ts 2 months ago
parent 8b067ea102
commit c92d347ac9

@ -74,7 +74,6 @@ import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import com.tailscale.ipn.ui.service.IpnManager;
import com.tailscale.ipn.ui.localapi.LocalApiClient;
@ -198,7 +197,7 @@ public class App extends Application {
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
public void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
@ -270,7 +269,7 @@ public class App extends Application {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
public void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);

@ -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"
}
}

@ -17,7 +17,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.service.IpnViewManager
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView
@ -38,11 +38,24 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val manager = IpnManager(lifecycleScope)
private val manager = IpnViewManager(lifecycleScope)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activity = this
val viewModel = MainViewModel(manager.model, manager)
lifecycleScope.launchWhenStarted {
viewModel.tileReady.collect { isTileReady ->
App.getApplication().setTileReady(isTileReady)
}
}
lifecycleScope.launchWhenStarted{
viewModel.readyToPrepareVPN.collect {
isReady -> if (isReady) App.getApplication().prepareVPN(activity, -1)
}
}
setContent {
AppTheme {
val navController = rememberNavController()
@ -63,7 +76,7 @@ class MainActivity : ComponentActivity() {
composable("main") {
MainView(
viewModel = MainViewModel(manager.model, manager),
viewModel = viewModel,
navigation = mainViewNav
)
}

@ -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)
}
}

@ -17,16 +17,15 @@ typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// Abstracts the actions that can be taken by the UI so that the concept of an IPNManager
// itself is hidden from the viewModel implementations.
interface IpnActions {
interface IpnViewActions {
fun startVPN()
fun stopVPN()
fun connect()
fun login()
fun logout()
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback)
}
class IpnManager(scope: CoroutineScope) : IpnActions {
class IpnViewManager(scope: CoroutineScope) : IpnViewActions {
private var notifier = Notifier()
var apiClient = LocalApiClient(scope)
@ -46,21 +45,7 @@ class IpnManager(scope: CoroutineScope) : IpnActions {
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
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("Connect: preferences updated successfully: $prefs")
} else if (result.failed) {
val error = result.error
Log.d("Connect: failed to update preferences: ${error?.message}")
}
}
model.setWantRunning(true, callback)
}
override fun login() {
apiClient.startLoginInteractive()
}

@ -7,9 +7,10 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnViewActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.PeerCategorizer
@ -18,7 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
class MainViewModel(val model: IpnModel, val actions: IpnViewActions) : ViewModel() {
// The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
@ -38,12 +39,22 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
// The state of the Quick Settings Tile
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
// Whether the VPN is ready to be prepared
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
var previousState: State? = null
model.state.collect { state ->
stateRes.set(state.userStringRes())
vpnToggleState.set((state == State.Running || state == State.Starting))
readyToPrepareVPN.set(previousState != null && previousState!! <= State.Stopped && state > State.Stopped)
tileReady.set(state >= State.Stopped)
previousState = state
}
}
@ -67,6 +78,8 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
}
fun toggleVpn() {
val stateValue = model.state.value
Log.d("MyApp", "Current VPN State: $stateValue")
when (model.state.value) {
State.Running -> actions.stopVPN()
else -> actions.startVPN()

@ -6,7 +6,7 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnViewActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav
@ -38,7 +38,7 @@ data class Setting(
data class SettingBundle(val title: String? = null, val settings: List<Setting>)
class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val navigation: SettingsNav) : ViewModel() {
class SettingsViewModel(val model: IpnModel, val ipnActions: IpnViewActions, val navigation: SettingsNav) : ViewModel() {
// The logged in user
val user = model.loggedInUser.value

@ -4,7 +4,9 @@
package localapiservice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
@ -12,6 +14,7 @@ import (
"net/http"
"time"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
@ -111,3 +114,22 @@ func (s *LocalAPIService) Logout(ctx context.Context, backend *ipnlocal.LocalBac
return err
}
func (s *LocalAPIService) Start(ctx context.Context, backend *ipnlocal.LocalBackend, options ipn.Options) error {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
body, err := json.Marshal(options)
if err != nil {
log.Printf("start: %s", err)
return err
}
r, err := s.Call(ctx, "POST", "/localapi/v0/start", bytes.NewReader(body))
defer r.Body().Close()
if err != nil {
log.Printf("start: %s", err)
backend.Start(options)
}
return err
}

@ -63,22 +63,6 @@ func Java_com_tailscale_ipn_IPNService_requestVPN(env *C.JNIEnv, this C.jobject)
onVPNRequested <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this))
}
//export Java_com_tailscale_ipn_IPNService_connect
func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: true}
}
//export Java_com_tailscale_ipn_IPNService_disconnect
func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
onDisconnect <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this))
}
//export Java_com_tailscale_ipn_StartVPNWorker_connect
func Java_com_tailscale_ipn_StartVPNWorker_connect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: true}
}
//export Java_com_tailscale_ipn_StopVPNWorker_disconnect
func Java_com_tailscale_ipn_StopVPNWorker_disconnect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: false}

@ -82,8 +82,6 @@ type App struct {
localAPI *localapiservice.LocalAPIService
backend *ipnlocal.LocalBackend
// netStates receives the most recent network state.
netStates chan BackendState
// invalidates receives whenever the window should be refreshed.
invalidates chan struct{}
}
@ -119,21 +117,20 @@ type settingsFunc func(*router.Config, *dns.OSConfig) error
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
type ConnectEvent struct {
Enable bool
}
const (
logPrefKey = "privatelogid"
loginMethodPrefKey = "loginmethod"
customLoginServerPrefKey = "customloginserver"
)
type ConnectEvent struct {
Enable bool
}
func main() {
a := &App{
jvm: (*jnipkg.JVM)(unsafe.Pointer(javaVM())),
appCtx: jnipkg.Object(appContext()),
netStates: make(chan BackendState, 1),
invalidates: make(chan struct{}, 1),
}
@ -207,6 +204,8 @@ func (a *App) runBackend(ctx context.Context) error {
state BackendState
service jnipkg.Object // of IPNService
)
a.localAPI.Start(ctx, b.backend, ipn.Options{})
for {
select {
case c := <-configs:

Loading…
Cancel
Save