android: prepare VPN when quick tile is clicked (#451)

Currently, the VPN is prepared when MainActivity is launched. If Tailscale is enabled by a quick tile, the VPN is not prepared.
This change creates an application scoped view model and moves the VPN prep to the application class so that it is not dependent on MainActivity.

Fixes tailscale/tailscale#12489

Signed-off-by: kari-ts <kari@tailscale.com>
pull/470/head
kari-ts 3 months ago committed by GitHub
parent e6fc832494
commit c6f3239b1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -9,7 +9,6 @@ import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties import android.net.LinkProperties
@ -22,6 +21,9 @@ import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
@ -30,6 +32,8 @@ import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -45,7 +49,7 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
@ -72,6 +76,15 @@ class App : UninitializedApp(), libtailscale.AppContext {
val dns = DnsConfig() val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore
lateinit var vpnViewModel: VpnViewModel
private set
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -108,6 +121,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
Notifier.stop() Notifier.stop()
notificationManager.cancelAll() notificationManager.cancelAll()
applicationScope.cancel() applicationScope.cancel()
viewModelStore.clear()
} }
private var isInitialized = false private var isInitialized = false
@ -146,6 +160,11 @@ class App : UninitializedApp(), libtailscale.AppContext {
QuickToggleService.setVPNRunning(vpnRunning) QuickToggleService.setVPNRunning(vpnRunning)
} }
} }
initViewModels()
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
} }
fun setWantRunning(wantRunning: Boolean) { fun setWantRunning(wantRunning: Boolean) {
@ -518,7 +537,8 @@ open class UninitializedApp : Application() {
} }
fun disallowedPackageNames(): List<String> { fun disallowedPackageNames(): List<String> {
val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) { if (mdmDisallowed.isNotEmpty()) {
Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed return builtInDisallowedPackageNames + mdmDisallowed

@ -18,7 +18,6 @@ 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
@ -31,6 +30,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@ -71,8 +71,10 @@ 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.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -82,7 +84,12 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.vpnViewModel
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
companion object { companion object {
private const val TAG = "Main Activity" private const val TAG = "Main Activity"
@ -105,6 +112,7 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
// (jonathan) TODO: Force the app to be portrait on small screens until we have // (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support // proper landscape layout support
@ -118,11 +126,11 @@ class MainActivity : ComponentActivity() {
registerForActivityResult(VpnPermissionContract()) { granted -> registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) { if (granted) {
Log.d("VpnPermission", "VPN permission granted") Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true) vpnViewModel.setVpnPrepared(true)
App.get().startVPN() App.get().startVPN()
} else { } else {
Log.d("VpnPermission", "VPN permission denied") Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false) vpnViewModel.setVpnPrepared(false)
} }
} }
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)

@ -70,6 +70,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
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.mdm.ShowHide import com.tailscale.ipn.mdm.ShowHide
@ -101,6 +102,7 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
@ -128,7 +130,7 @@ fun MainView(
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared // Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
// cannot be known // cannot be known
// until permission has been granted to prepare the VPN. // until permission has been granted to prepare the VPN.
val isPrepared by viewModel.vpnPrepared.collectAsState(initial = true) val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
val isOn by viewModel.vpnToggleState.collectAsState(initial = false) 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)
@ -283,7 +285,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
Text( Text(
text = text =
managedByOrganization.value?.let { managedByOrganization?.let {
stringResource(R.string.exit_node_offline_mdm_orgname, it) stringResource(R.string.exit_node_offline_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_offline_mdm), } ?: stringResource(R.string.exit_node_offline_mdm),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -735,7 +737,9 @@ fun PromptPermissionsIfNecessary() {
@Preview @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {
val vm = MainViewModel() val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
MainView( MainView(
{}, {},
MainViewNavigation( MainViewNavigation(

@ -37,9 +37,11 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
@Composable @Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) { fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState() val user by viewModel.loggedInUser.collectAsState()
@ -47,7 +49,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val managedByOrganization by viewModel.managedByOrganization.collectAsState() val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by viewModel.vpnPrepared.collectAsState() val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
Scaffold( Scaffold(

@ -3,11 +3,9 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.net.VpnService
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
@ -64,16 +62,6 @@ open class IpnViewModel : ViewModel() {
} }
init { init {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
setVpnPrepared(false)
} else {
setVpnPrepared(true)
}
}
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { Notifier.state.collect {
// Reload the user profiles on all state transitions to ensure loggedInUser is correct // Reload the user profiles on all state transitions to ensure loggedInUser is correct

@ -8,6 +8,8 @@ import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
@ -27,7 +29,16 @@ 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 MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
@ -56,6 +67,8 @@ class MainViewModel : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel() var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -81,7 +94,8 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared } combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared }
combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared }
.collect { (currentState, prepared) -> .collect { (currentState, prepared) ->
stateRes.set(userStringRes(currentState, previousState, prepared)) stateRes.set(userStringRes(currentState, previousState, prepared))
@ -116,7 +130,6 @@ class MainViewModel : IpnViewModel() {
} }
} }
} }
}
viewModelScope.launch { viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
@ -126,20 +139,22 @@ class MainViewModel : IpnViewModel() {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
} }
} }
}
fun showVPNPermissionLauncherIfUnauthorized() { fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get()) val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) { if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent) vpnPermissionLauncher?.launch(vpnIntent)
} else { } else {
setVpnPrepared(true) vpnViewModel.setVpnPrepared(true)
vpnViewModel.setVpnPrepared(true)
startVPN() startVPN()
} }
} }
fun toggleVpn() { fun toggleVpn() {
val state = Notifier.state.value val state = Notifier.state.value
val isPrepared = vpnPrepared.value val isPrepared = vpnViewModel.vpnPrepared.value
when { when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized() !isPrepared -> showVPNPermissionLauncherIfUnauthorized()

@ -47,11 +47,7 @@ class SettingsViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
Notifier.prefs.collect { Notifier.prefs.collect {
it?.let { it?.let { corpDNSEnabled.set(it.CorpDNS) } ?: run { corpDNSEnabled.set(null) }
corpDNSEnabled.set(it.CorpDNS)
} ?: run {
corpDNSEnabled.set(null)
}
} }
} }
} }

@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.VpnService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(VpnViewModel::class.java)) {
return VpnViewModel(application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
init {
prepareVpn()
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
} else {
setVpnPrepared(true)
}
}
}
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
}
Loading…
Cancel
Save