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 <kari@tailscale.com>
pull/647/head
kari-ts 7 months ago
parent ca7dc5f8a8
commit 8e67cdb805

@ -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"

@ -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()

@ -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<Intent>? = null
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(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) {

Loading…
Cancel
Save