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) {