From 8e67cdb805c47baf8b5c67768171d95ed3c59c06 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Fri, 9 May 2025 12:00:43 -0700 Subject: [PATCH] android: defer vpn permission until activity is resumed Right now, we register the launcher in MainActivity.onCreate(), inject this into the ViewModel, then show the launcher in MainView. There is no guarantee that the activity is in RESUMED when the Composable runs, showing the launcher. This can lead to a silent RESULT_CANCELED on some OEMs. The fix is to add a lifecycle-aware wrapper that defers the launch. Updates tailscale/tailscale#15419 Signed-off-by: kari-ts --- .../com/tailscale/ipn/ui/localapi/Client.kt | 6 ++--- .../com/tailscale/ipn/ui/view/MainView.kt | 22 +++++++++++++++++-- .../ipn/ui/viewModel/MainViewModel.kt | 9 +++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 5b38b6a..2a30db4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -14,9 +14,6 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.util.TSLog -import java.nio.charset.Charset -import kotlin.reflect.KType -import kotlin.reflect.typeOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -26,6 +23,9 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer import libtailscale.FilePart +import java.nio.charset.Charset +import kotlin.reflect.KType +import kotlin.reflect.typeOf private object Endpoint { const val DEBUG = "debug" 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 90e99e6..fdb16bb 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,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.tailscale.ipn.App import com.tailscale.ipn.R @@ -207,8 +210,8 @@ fun MainView( Ipn.State.Running -> { PromptPermissionsIfNecessary() - - viewModel.showVPNPermissionLauncherIfUnauthorized() + viewModel.maybeRequestVpnPermission() + LaunchVpnPermissionIfNeeded(viewModel) if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -253,6 +256,21 @@ fun MainView( } } +@Composable +fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { + val lifecycleOwner = LocalLifecycleOwner.current + val shouldRequest by viewModel.requestVpnPermission.collectAsState() + + LaunchedEffect(shouldRequest) { + if (!shouldRequest) return@LaunchedEffect + + // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.showVPNPermissionLauncherIfUnauthorized() + } + } +} + @Composable fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() 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 2d75841..c0e205a 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 @@ -25,7 +25,6 @@ import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set -import java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import java.time.Duration class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -60,6 +60,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null + private val _requestVpnPermission = MutableStateFlow(false) + val requestVpnPermission: StateFlow = _requestVpnPermission // The list of peers private val _peers = MutableStateFlow>(emptyList()) @@ -187,6 +189,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } + fun maybeRequestVpnPermission() { + _requestVpnPermission.value = true + } + fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) if (vpnIntent != null) { @@ -195,6 +201,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { vpnViewModel.setVpnPrepared(true) startVPN() } + _requestVpnPermission.value = false // reset } fun toggleVpn(desiredState: Boolean) {