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.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.LinkProperties
@ -22,6 +21,9 @@ import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
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.MasterKey
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.notifier.HealthNotifier
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -45,7 +49,7 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext {
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
@ -72,6 +76,15 @@ class App : UninitializedApp(), libtailscale.AppContext {
val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager
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
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -108,6 +121,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
Notifier.stop()
notificationManager.cancelAll()
applicationScope.cancel()
viewModelStore.clear()
}
private var isInitialized = false
@ -146,6 +160,11 @@ class App : UninitializedApp(), libtailscale.AppContext {
QuickToggleService.setVPNRunning(vpnRunning)
}
}
initViewModels()
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean) {
@ -518,7 +537,8 @@ open class UninitializedApp : Application() {
}
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()) {
Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed

@ -18,7 +18,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -31,6 +30,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
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.viewModel.ExitNodePickerNav
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.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@ -82,7 +84,12 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
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 {
private const val TAG = "Main Activity"
@ -105,6 +112,7 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
@ -118,11 +126,11 @@ class MainActivity : ComponentActivity() {
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true)
vpnViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false)
vpnViewModel.setVpnPrepared(false)
}
}
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.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
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.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
// Navigation actions for the MainView
data class MainViewNavigation(
@ -128,7 +130,7 @@ fun MainView(
// Assume VPN has been prepared for optimistic UI. 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 isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
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)) {
Text(
text =
managedByOrganization.value?.let {
managedByOrganization?.let {
stringResource(R.string.exit_node_offline_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_offline_mdm),
style = MaterialTheme.typography.bodyMedium,
@ -735,7 +737,9 @@ fun PromptPermissionsIfNecessary() {
@Preview
@Composable
fun MainViewPreview() {
val vm = MainViewModel()
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
MainView(
{},
MainViewNavigation(

@ -37,9 +37,11 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) {
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState()
@ -47,7 +49,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.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()
Scaffold(

@ -3,11 +3,9 @@
package com.tailscale.ipn.ui.viewModel
import android.net.VpnService
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
@ -64,16 +62,6 @@ open class IpnViewModel : ViewModel() {
}
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 {
Notifier.state.collect {
// 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.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
@ -27,7 +29,16 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
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
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
@ -56,6 +67,8 @@ class MainViewModel : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
// Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -81,7 +94,8 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
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) ->
stateRes.set(userStringRes(currentState, previousState, prepared))
@ -116,7 +130,6 @@ class MainViewModel : IpnViewModel() {
}
}
}
}
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
@ -126,20 +139,22 @@ class MainViewModel : IpnViewModel() {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
}
}
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
setVpnPrepared(true)
vpnViewModel.setVpnPrepared(true)
vpnViewModel.setVpnPrepared(true)
startVPN()
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnPrepared.value
val isPrepared = vpnViewModel.vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()

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