From c6f3239b1b7d6cecf7eb437485525c1f82abee32 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 16 Aug 2024 13:58:58 -0700 Subject: [PATCH] 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 --- .../src/main/java/com/tailscale/ipn/App.kt | 26 ++++++++-- .../java/com/tailscale/ipn/MainActivity.kt | 16 +++++-- .../com/tailscale/ipn/ui/view/MainView.kt | 10 ++-- .../com/tailscale/ipn/ui/view/SettingsView.kt | 6 ++- .../ipn/ui/viewModel/IpnViewModel.kt | 12 ----- .../ipn/ui/viewModel/MainViewModel.kt | 35 ++++++++++---- .../ipn/ui/viewModel/SettingsViewModel.kt | 6 +-- .../ipn/ui/viewModel/VpnViewModel.kt | 48 +++++++++++++++++++ 8 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index caae4ac..6d8a58e 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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 { - 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 diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 70d8491..98b046a 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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 - 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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index eead8ab..bddffc0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 0586126..d83f50d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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( diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index db7ebda..c38b5c0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index d4a6e03..c533615 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 create(modelClass: Class): 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 = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) @@ -56,6 +67,8 @@ class MainViewModel : IpnViewModel() { var pingViewModel: PingViewModel = PingViewModel() + val isVpnPrepared: StateFlow = vpnViewModel.vpnPrepared + // Icon displayed in the button to present the health view val healthIcon: StateFlow = 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,14 +130,14 @@ class MainViewModel : IpnViewModel() { } } } - } - viewModelScope.launch { - searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } - } + viewModelScope.launch { + searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } + } - viewModelScope.launch { - App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } + viewModelScope.launch { + App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } + } } } @@ -132,14 +146,15 @@ class MainViewModel : IpnViewModel() { 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() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 51aaf8b..50095eb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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) } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt new file mode 100644 index 0000000..5a0d078 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt @@ -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 create(modelClass: Class): 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 = _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 + } +}