android: fix connect VPN permissions (#398)

-show VPN connection permissions after intro screen
-make toggle state and main view take VPN preparedness into consideration

Fixes tailscale/tailscale#12148

Signed-off-by: kari-ts <kari@tailscale.com>
pull/400/head
kari-ts 7 months ago committed by GitHub
parent d842ccde22
commit 12ad295706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,9 +3,7 @@
package com.tailscale.ipn package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.Fragment
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
@ -18,7 +16,6 @@ import android.net.LinkProperties
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
@ -51,7 +48,6 @@ class App : UninitializedApp(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App" private const val TAG = "App"
private val networkConnectivityRequest = private val networkConnectivityRequest =
@ -61,12 +57,6 @@ class App : UninitializedApp(), libtailscale.AppContext {
.build() .build()
private lateinit var appInstance: App private lateinit var appInstance: App
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
/** /**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this * Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes. * function to obtain an App reference to make sure the app initializes.
@ -246,20 +236,6 @@ class App : UninitializedApp(), libtailscale.AppContext {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
fun prepareVPN(act: Activity, reqCode: Int) {
// We do this with UI in case it's our first time starting the VPN.
act.runOnUiThread {
val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) {
// No intent here means that we already have permission to be a VPN.
startVPN()
} else {
// An intent here means that we need to prompt for permission to be a VPN.
startActivityForResult(act, prepareIntent, reqCode)
}
}
}
override fun getInterfacesAsString(): String { override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> = val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) java.util.Collections.list(NetworkInterface.getNetworkInterfaces())

@ -19,6 +19,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -38,7 +39,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
@ -68,6 +68,7 @@ import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -78,6 +79,8 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit> private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels()
companion object { companion object {
private const val TAG = "Main Activity" private const val TAG = "Main Activity"
@ -109,6 +112,18 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true)
} else {
Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false)
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent { setContent {
AppTheme { AppTheme {
navController = rememberNavController() navController = rememberNavController()
@ -176,7 +191,7 @@ class MainActivity : ComponentActivity() {
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav) MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
} }
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
@ -231,13 +246,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
lifecycleScope.launch {
Notifier.state.collect { state ->
if (state > Ipn.State.Stopped) {
App.get().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
}
}
} }
init { init {
@ -322,10 +330,6 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
} }
override fun onStop() { override fun onStop() {
@ -335,18 +339,6 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) } lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
} }
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
}
}
private fun openApplicationSettings() { private fun openApplicationSettings() {
val intent = val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
@ -368,9 +360,9 @@ class MainActivity : ComponentActivity() {
} }
} }
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() { class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent { override fun createIntent(context: Context, input: Intent): Intent {
return VpnService.prepare(context) ?: Intent() return input
} }
override fun parseResult(resultCode: Int, intent: Intent?): Boolean { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {

@ -1,28 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
private static int resultOK = -1;
public class RequestCodes {
public static final int requestPrepareVPN = 1001;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == RequestCodes.requestPrepareVPN) {
if (resultCode == resultOK) {
UninitializedApp.get().startVPN();
} else {
App.get().setWantRunning(false);
// notify VPN revoked
}
}
}
}

@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -88,6 +89,7 @@ import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import android.util.Log
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
@ -101,14 +103,17 @@ data class MainViewNavigation(
fun MainView( fun MainView(
loginAtUrl: (String) -> Unit, loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation, navigation: MainViewNavigation,
viewModel: MainViewModel = viewModel() viewModel: MainViewModel
) { ) {
val isOn = viewModel.vpnToggleState.collectAsState()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column( Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets), modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
// Assume VPN has been prepared. Whether or not it has been prepared cannot be known until permission has been granted to prepare the VPN.
val isPrepared by viewModel.vpnPrepared.collectAsState(initial=true)
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user by viewModel.loggedInUser.collectAsState(initial = null) val user by viewModel.loggedInUser.collectAsState(initial = null)
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder) val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
@ -128,7 +133,7 @@ fun MainView(
} }
}, },
enabled = !disableToggle, enabled = !disableToggle,
checked = isOn.value) checked = isOn)
}, },
headlineContent = { headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain -> user?.NetworkProfile?.DomainName?.let { domain ->
@ -185,11 +190,13 @@ fun MainView(
else -> { else -> {
ConnectView( ConnectView(
state, state,
isPrepared,
user, user,
{ viewModel.toggleVpn() }, { viewModel.toggleVpn() },
{ viewModel.login() }, { viewModel.login() },
loginAtUrl, loginAtUrl,
netmap?.SelfNode) netmap?.SelfNode,
{viewModel.showVPNPermissionLauncherIfUnauthorized()})
} }
} }
} }
@ -300,12 +307,19 @@ fun StartingView() {
@Composable @Composable
fun ConnectView( fun ConnectView(
state: Ipn.State, state: Ipn.State,
isPrepared: Boolean,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit, loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit, loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node? selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
LaunchedEffect(isPrepared) {
if (!isPrepared) {
showVPNPermissionLauncherIfUnauthorized()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column( Column(
@ -313,7 +327,24 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (state == Ipn.State.NeedsMachineAuth) { if (!isPrepared) {
TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
stringResource(R.string.give_permissions),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
} else if (state == Ipn.State.NeedsMachineAuth) {
Icon( Icon(
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
imageVector = Icons.Outlined.Lock, imageVector = Icons.Outlined.Lock,

@ -59,19 +59,11 @@ open class IpnViewModel : ViewModel() {
} }
// VPN Control // VPN Control
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
fun startVPN() { fun startVPN() {
UninitializedApp.get().startVPN() UninitializedApp.get().startVPN()
} }
private fun stopVPN() { fun stopVPN() {
UninitializedApp.get().stopVPN() UninitializedApp.get().stopVPN()
} }

@ -3,10 +3,15 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
@ -16,18 +21,24 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.App import com.tailscale.ipn.App
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration import java.time.Duration
class MainViewModel : IpnViewModel() { class MainViewModel : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes(null)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle // The expected state of the VPN toggle
private val _vpnToggleState = MutableStateFlow(false) private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Whether or not the VPN has been prepared
private val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers // The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>()) val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
@ -49,16 +60,20 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
Notifier.state.collect { currentState ->
val userString = currentState.userStringRes(previousState) combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared }
stateRes.set(userString) .collect { (currentState, prepared) ->
_vpnToggleState.value = when { stateRes.set(userStringRes(currentState, previousState, prepared))
currentState == State.Running || currentState == State.Starting -> true
previousState == State.NoState && currentState == State.Starting -> true val isOn = when {
else -> false currentState == State.Running || currentState == State.Starting -> true
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
_vpnToggleState.value = isOn
previousState = currentState
} }
previousState = currentState
}
} }
viewModelScope.launch { viewModelScope.launch {
@ -91,21 +106,47 @@ class MainViewModel : IpnViewModel() {
} }
} }
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
state == Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
vpnPermissionLauncher = launcher
}
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
} }
private fun State?.userStringRes(previousState: State?): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
val resId = when { return when {
previousState == State.NoState && this == State.Starting -> R.string.starting previousState == State.NoState && currentState == State.Starting -> R.string.starting
this == State.NoState -> R.string.placeholder currentState == State.NoState -> R.string.placeholder
this == State.InUseOtherUser -> R.string.placeholder currentState == State.InUseOtherUser -> R.string.placeholder
this == State.NeedsLogin -> R.string.please_login currentState == State.NeedsLogin -> if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
this == State.NeedsMachineAuth -> R.string.needs_machine_auth currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
this == State.Stopped -> R.string.stopped currentState == State.Stopped -> R.string.stopped
this == State.Starting -> R.string.starting currentState == State.Starting -> R.string.starting
this == State.Running -> R.string.connected currentState == State.Running -> R.string.connected
else -> R.string.placeholder else -> R.string.placeholder
} }
return resId return resId

@ -58,6 +58,7 @@
<string name="connect_to_tailnet_suffix">" tailnet."</string> <string name="connect_to_tailnet_suffix">" tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string> <string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string> <string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<string name="give_permissions">Set up a VPN connection so you can start using Tailscale.</string>
<string name="keyExpiryExplainer">Reauthenticate to remain connected to Tailscale.</string> <string name="keyExpiryExplainer">Reauthenticate to remain connected to Tailscale.</string>
<string name="deviceKeyNeverExpires">Device key does not expire</string> <string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %s</string> <string name="deviceKeyExpires">Device key expires %s</string>
@ -87,6 +88,7 @@
<string name="placeholder">--</string> <string name="placeholder">--</string>
<string name="please_login">Login required</string> <string name="please_login">Login required</string>
<string name="stopped">Not connected</string> <string name="stopped">Not connected</string>
<string name="connect_to_vpn">Connect to VPN</string>
<!-- Time conversion templates --> <!-- Time conversion templates -->
<string name="expired">expired</string> <string name="expired">expired</string>

Loading…
Cancel
Save