Skip to content

android: defer vpn permission until activity is resumed #647

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
22 changes: 20 additions & 2 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -207,8 +210,8 @@ fun MainView(
Ipn.State.Running -> {

PromptPermissionsIfNecessary()

viewModel.showVPNPermissionLauncherIfUnauthorized()
viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel)

if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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
Comment on lines +63 to +64

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When initializing the _requestVpnPermission MutableStateFlow, consider explicitly setting the initial value to false for clarity rather than relying on the default value. This would make the initial state more obvious to future readers.


// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
Expand Down Expand Up @@ -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) {
Expand All @@ -195,6 +201,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
vpnViewModel.setVpnPrepared(true)
startVPN()
}
_requestVpnPermission.value = false // reset
}

fun toggleVpn(desiredState: Boolean) {
Expand Down