Skip to content

Commit d3f34c5

Browse files
authored
android: defer vpn permission until activity is resumed (#647)
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 <[email protected]>
1 parent ca7dc5f commit d3f34c5

File tree

3 files changed

+31
-6
lines changed

3 files changed

+31
-6
lines changed

android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ import com.tailscale.ipn.ui.model.StableNodeID
1414
import com.tailscale.ipn.ui.model.Tailcfg
1515
import com.tailscale.ipn.ui.util.InputStreamAdapter
1616
import com.tailscale.ipn.util.TSLog
17-
import java.nio.charset.Charset
18-
import kotlin.reflect.KType
19-
import kotlin.reflect.typeOf
2017
import kotlinx.coroutines.CoroutineScope
2118
import kotlinx.coroutines.Dispatchers
2219
import kotlinx.coroutines.launch
@@ -26,6 +23,9 @@ import kotlinx.serialization.json.Json
2623
import kotlinx.serialization.json.decodeFromStream
2724
import kotlinx.serialization.serializer
2825
import libtailscale.FilePart
26+
import java.nio.charset.Charset
27+
import kotlin.reflect.KType
28+
import kotlin.reflect.typeOf
2929

3030
private object Endpoint {
3131
const val DEBUG = "debug"

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ import androidx.compose.ui.text.style.TextAlign
7070
import androidx.compose.ui.text.style.TextOverflow
7171
import androidx.compose.ui.tooling.preview.Preview
7272
import androidx.compose.ui.unit.dp
73+
import androidx.lifecycle.Lifecycle
74+
import androidx.lifecycle.compose.LocalLifecycleOwner
75+
import androidx.lifecycle.repeatOnLifecycle
7376
import com.google.accompanist.permissions.ExperimentalPermissionsApi
7477
import com.tailscale.ipn.App
7578
import com.tailscale.ipn.R
@@ -207,8 +210,8 @@ fun MainView(
207210
Ipn.State.Running -> {
208211

209212
PromptPermissionsIfNecessary()
210-
211-
viewModel.showVPNPermissionLauncherIfUnauthorized()
213+
viewModel.maybeRequestVpnPermission()
214+
LaunchVpnPermissionIfNeeded(viewModel)
212215

213216
if (showKeyExpiry) {
214217
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
@@ -253,6 +256,21 @@ fun MainView(
253256
}
254257
}
255258

259+
@Composable
260+
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
261+
val lifecycleOwner = LocalLifecycleOwner.current
262+
val shouldRequest by viewModel.requestVpnPermission.collectAsState()
263+
264+
LaunchedEffect(shouldRequest) {
265+
if (!shouldRequest) return@LaunchedEffect
266+
267+
// Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED
268+
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
269+
viewModel.showVPNPermissionLauncherIfUnauthorized()
270+
}
271+
}
272+
}
273+
256274
@Composable
257275
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
258276
val nodeState by viewModel.nodeState.collectAsState()

android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
2525
import com.tailscale.ipn.ui.util.PeerSet
2626
import com.tailscale.ipn.ui.util.TimeUtil
2727
import com.tailscale.ipn.ui.util.set
28-
import java.time.Duration
2928
import kotlinx.coroutines.Dispatchers
3029
import kotlinx.coroutines.FlowPreview
3130
import kotlinx.coroutines.Job
@@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
3433
import kotlinx.coroutines.flow.combine
3534
import kotlinx.coroutines.flow.debounce
3635
import kotlinx.coroutines.launch
36+
import java.time.Duration
3737

3838
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
3939
@Suppress("UNCHECKED_CAST")
@@ -60,6 +60,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
6060

6161
// Permission to prepare VPN
6262
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
63+
private val _requestVpnPermission = MutableStateFlow(false)
64+
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
6365

6466
// The list of peers
6567
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
@@ -187,6 +189,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
187189
}
188190
}
189191

192+
fun maybeRequestVpnPermission() {
193+
_requestVpnPermission.value = true
194+
}
195+
190196
fun showVPNPermissionLauncherIfUnauthorized() {
191197
val vpnIntent = VpnService.prepare(App.get())
192198
if (vpnIntent != null) {
@@ -195,6 +201,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
195201
vpnViewModel.setVpnPrepared(true)
196202
startVPN()
197203
}
204+
_requestVpnPermission.value = false // reset
198205
}
199206

200207
fun toggleVpn(desiredState: Boolean) {

0 commit comments

Comments
 (0)