Skip to content

Commit 290c487

Browse files
committed
android: replace broadcast intent with service intent
We were previously calling startService(intent), which is a direct call consumed by IPNService, but restartVPN was not working as intended because the broadcast receiver was never triggered. Rather than use a broadcast receiver, directly start the service in restartVPN as we do in stopVPN. Also, batch changes to excluded apps so that we don't restart the VPN each time the user toggles an app. Fixes tailscale/corp#28668 Signed-off-by: kari-ts <[email protected]>
1 parent d3f34c5 commit 290c487

File tree

3 files changed

+45
-56
lines changed

3 files changed

+45
-56
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

+18-49
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import android.app.Application
77
import android.app.Notification
88
import android.app.NotificationChannel
99
import android.app.PendingIntent
10-
import android.content.BroadcastReceiver
1110
import android.content.Context
1211
import android.content.Intent
1312
import android.content.IntentFilter
@@ -37,11 +36,6 @@ import com.tailscale.ipn.ui.viewModel.VpnViewModel
3736
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
3837
import com.tailscale.ipn.util.FeatureFlags
3938
import com.tailscale.ipn.util.TSLog
40-
import java.io.File
41-
import java.io.IOException
42-
import java.net.NetworkInterface
43-
import java.security.GeneralSecurityException
44-
import java.util.Locale
4539
import kotlinx.coroutines.CoroutineScope
4640
import kotlinx.coroutines.Dispatchers
4741
import kotlinx.coroutines.SupervisorJob
@@ -53,6 +47,11 @@ import kotlinx.coroutines.launch
5347
import kotlinx.serialization.encodeToString
5448
import kotlinx.serialization.json.Json
5549
import libtailscale.Libtailscale
50+
import java.io.File
51+
import java.io.IOException
52+
import java.net.NetworkInterface
53+
import java.security.GeneralSecurityException
54+
import java.util.Locale
5655

5756
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5857
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -470,25 +469,15 @@ open class UninitializedApp : Application() {
470469
}
471470

472471
fun restartVPN() {
473-
// Register a receiver to listen for the completion of stopVPN
474-
val stopReceiver =
475-
object : BroadcastReceiver() {
476-
override fun onReceive(context: Context?, intent: Intent?) {
477-
// Ensure stop intent is complete
478-
if (intent?.action == IPNService.ACTION_STOP_VPN) {
479-
// Unregister receiver after receiving the broadcast
480-
context?.unregisterReceiver(this)
481-
// Now start the VPN
482-
startVPN()
483-
}
484-
}
485-
}
486-
487-
// Register the receiver before stopping VPN
488-
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
489-
this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
490-
491-
stopVPN()
472+
val intent =
473+
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
474+
try {
475+
startService(intent)
476+
} catch (illegalStateException: IllegalStateException) {
477+
TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException")
478+
} catch (e: Exception) {
479+
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
480+
}
492481
}
493482

494483
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@@ -569,33 +558,13 @@ open class UninitializedApp : Application() {
569558
return builder.build()
570559
}
571560

572-
fun addUserDisallowedPackageName(packageName: String) {
573-
if (packageName.isEmpty()) {
574-
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
575-
return
576-
}
577-
578-
getUnencryptedPrefs()
579-
.edit()
580-
.putStringSet(
581-
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
582-
.apply()
583-
584-
this.restartVPN()
585-
}
586-
587-
fun removeUserDisallowedPackageName(packageName: String) {
588-
if (packageName.isEmpty()) {
589-
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
561+
fun updateUserDisallowedPackageNames(packageNames: List<String>) {
562+
if (packageNames.any { it.isEmpty() }) {
563+
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
590564
return
591565
}
592566

593-
getUnencryptedPrefs()
594-
.edit()
595-
.putStringSet(
596-
DISALLOWED_APPS_KEY,
597-
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
598-
.apply()
567+
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
599568

600569
this.restartVPN()
601570
}

android/src/main/java/com/tailscale/ipn/IPNService.kt

+9-2
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
1212
import com.tailscale.ipn.ui.model.Ipn
1313
import com.tailscale.ipn.ui.notifier.Notifier
1414
import com.tailscale.ipn.util.TSLog
15-
import java.util.UUID
1615
import kotlinx.coroutines.CoroutineScope
1716
import kotlinx.coroutines.Dispatchers
1817
import kotlinx.coroutines.flow.first
1918
import kotlinx.coroutines.launch
2019
import libtailscale.Libtailscale
20+
import java.util.UUID
2121

2222
open class IPNService : VpnService(), libtailscale.IPNService {
2323
private val TAG = "IPNService"
@@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService {
4646
close()
4747
START_NOT_STICKY
4848
}
49+
ACTION_RESTART_VPN -> {
50+
app.setWantRunning(false){
51+
close()
52+
app.startVPN()
53+
}
54+
START_NOT_STICKY
55+
}
4956
ACTION_START_VPN -> {
5057
scope.launch { showForegroundNotification() }
5158
app.setWantRunning(true)
@@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
8289
}
8390

8491
override fun close() {
85-
app.setWantRunning(false) {}
8692
Notifier.setState(Ipn.State.Stopping)
8793
disconnectVPN()
8894
Libtailscale.serviceDisconnect(this)
@@ -180,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
180186
companion object {
181187
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
182188
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
189+
const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN"
183190
}
184191
}

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

+18-5
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44
package com.tailscale.ipn.ui.viewModel
55

66
import androidx.lifecycle.ViewModel
7+
import androidx.lifecycle.viewModelScope
78
import com.tailscale.ipn.App
89
import com.tailscale.ipn.mdm.MDMSettings
910
import com.tailscale.ipn.mdm.SettingState
1011
import com.tailscale.ipn.ui.util.InstalledApp
1112
import com.tailscale.ipn.ui.util.InstalledAppsManager
1213
import com.tailscale.ipn.ui.util.set
14+
import kotlinx.coroutines.Job
15+
import kotlinx.coroutines.delay
1316
import kotlinx.coroutines.flow.MutableStateFlow
1417
import kotlinx.coroutines.flow.StateFlow
18+
import kotlinx.coroutines.launch
1519

1620
class SplitTunnelAppPickerViewModel : ViewModel() {
1721
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
@@ -20,6 +24,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
2024
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
2125
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
2226

27+
private var saveJob: Job? = null
28+
2329
init {
2430
installedApps.set(installedAppsManager.fetchInstalledApps())
2531
excludedPackageNames.set(
@@ -30,15 +36,22 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
3036
}
3137

3238
fun exclude(packageName: String) {
33-
if (excludedPackageNames.value.contains(packageName)) {
34-
return
35-
}
39+
if (excludedPackageNames.value.contains(packageName)) return
3640
excludedPackageNames.set(excludedPackageNames.value + packageName)
37-
App.get().addUserDisallowedPackageName(packageName)
41+
debounceSave()
3842
}
3943

4044
fun unexclude(packageName: String) {
4145
excludedPackageNames.set(excludedPackageNames.value - packageName)
42-
App.get().removeUserDisallowedPackageName(packageName)
46+
debounceSave()
47+
}
48+
49+
private fun debounceSave() {
50+
saveJob?.cancel()
51+
saveJob =
52+
viewModelScope.launch {
53+
delay(500) // Wait to batch multiple rapid updates
54+
App.get().updateUserDisallowedPackageNames(excludedPackageNames.value)
55+
}
4356
}
4457
}

0 commit comments

Comments
 (0)