From 81ff8987829f9c4200632403592edb2f27d0db36 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 10:42:43 -0700 Subject: [PATCH] android: replace broadcast intent with service intent (#650) 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 --- .../src/main/java/com/tailscale/ipn/App.kt | 67 +++++-------------- .../main/java/com/tailscale/ipn/IPNService.kt | 11 ++- .../SplitTunnelAppPickerViewModel.kt | 23 +++++-- 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a91325e..3e834cb 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -7,7 +7,6 @@ import android.app.Application import android.app.Notification import android.app.NotificationChannel import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -37,11 +36,6 @@ import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.TSLog -import java.io.File -import java.io.IOException -import java.net.NetworkInterface -import java.security.GeneralSecurityException -import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -53,6 +47,11 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale +import java.io.File +import java.io.IOException +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -470,25 +469,15 @@ open class UninitializedApp : Application() { } fun restartVPN() { - // Register a receiver to listen for the completion of stopVPN - val stopReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - // Ensure stop intent is complete - if (intent?.action == IPNService.ACTION_STOP_VPN) { - // Unregister receiver after receiving the broadcast - context?.unregisterReceiver(this) - // Now start the VPN - startVPN() - } - } - } - - // Register the receiver before stopping VPN - val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN) - this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) - - stopVPN() + val intent = + Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } + try { + startService(intent) + } catch (illegalStateException: IllegalStateException) { + TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException") + } catch (e: Exception) { + TSLog.e(TAG, "restartVPN hit exception in startService(): $e") + } } fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { @@ -569,33 +558,13 @@ open class UninitializedApp : Application() { return builder.build() } - fun addUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") - return - } - - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName))) - .apply() - - this.restartVPN() - } - - fun removeUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + fun updateUserDisallowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") return } - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, - disallowedPackageNames().toMutableSet().subtract(setOf(packageName))) - .apply() + getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() this.restartVPN() } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 917b405..e861d9c 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.util.TSLog -import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import libtailscale.Libtailscale +import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" @@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService { close() START_NOT_STICKY } + ACTION_RESTART_VPN -> { + app.setWantRunning(false){ + close() + app.startVPN() + } + START_NOT_STICKY + } ACTION_START_VPN -> { scope.launch { showForegroundNotification() } app.setWantRunning(true) @@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { } override fun close() { - app.setWantRunning(false) {} Notifier.setState(Ipn.State.Stopping) disconnectVPN() Libtailscale.serviceDisconnect(this) @@ -180,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" + const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN" } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index d00efb6..7611f05 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -4,14 +4,18 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) @@ -20,6 +24,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow + private var saveJob: Job? = null + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -30,15 +36,22 @@ class SplitTunnelAppPickerViewModel : ViewModel() { } fun exclude(packageName: String) { - if (excludedPackageNames.value.contains(packageName)) { - return - } + if (excludedPackageNames.value.contains(packageName)) return excludedPackageNames.set(excludedPackageNames.value + packageName) - App.get().addUserDisallowedPackageName(packageName) + debounceSave() } fun unexclude(packageName: String) { excludedPackageNames.set(excludedPackageNames.value - packageName) - App.get().removeUserDisallowedPackageName(packageName) + debounceSave() + } + + private fun debounceSave() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) // Wait to batch multiple rapid updates + App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) + } } }