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 <kari@tailscale.com>
pull/653/head^2
kari-ts 7 months ago committed by GitHub
parent e3c76eb812
commit 81ff898782
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -7,7 +7,6 @@ import android.app.Application
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter 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.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.TSLog 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -53,6 +47,11 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import libtailscale.Libtailscale 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 { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -470,25 +469,15 @@ open class UninitializedApp : Application() {
} }
fun restartVPN() { fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN val intent =
val stopReceiver = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
object : BroadcastReceiver() { try {
override fun onReceive(context: Context?, intent: Intent?) { startService(intent)
// Ensure stop intent is complete } catch (illegalStateException: IllegalStateException) {
if (intent?.action == IPNService.ACTION_STOP_VPN) { TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException")
// Unregister receiver after receiving the broadcast } catch (e: Exception) {
context?.unregisterReceiver(this) TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
// 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()
} }
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@ -569,33 +558,13 @@ open class UninitializedApp : Application() {
return builder.build() return builder.build()
} }
fun addUserDisallowedPackageName(packageName: String) { fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageName.isEmpty()) { if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
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")
return return
} }
getUnencryptedPrefs() getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
this.restartVPN() this.restartVPN()
} }

@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.util.UUID
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService" private val TAG = "IPNService"
@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService {
close() close()
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_RESTART_VPN -> {
app.setWantRunning(false){
close()
app.startVPN()
}
START_NOT_STICKY
}
ACTION_START_VPN -> { ACTION_START_VPN -> {
scope.launch { showForegroundNotification() } scope.launch { showForegroundNotification() }
app.setWantRunning(true) app.setWantRunning(true)
@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
override fun close() { override fun close() {
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping) Notifier.setState(Ipn.State.Stopping)
disconnectVPN() disconnectVPN()
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
@ -180,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
companion object { companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN"
} }
} }

@ -4,14 +4,18 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.mdm.SettingState
import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SplitTunnelAppPickerViewModel : ViewModel() { class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
@ -20,6 +24,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
private var saveJob: Job? = null
init { init {
installedApps.set(installedAppsManager.fetchInstalledApps()) installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set( excludedPackageNames.set(
@ -30,15 +36,22 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
} }
fun exclude(packageName: String) { fun exclude(packageName: String) {
if (excludedPackageNames.value.contains(packageName)) { if (excludedPackageNames.value.contains(packageName)) return
return
}
excludedPackageNames.set(excludedPackageNames.value + packageName) excludedPackageNames.set(excludedPackageNames.value + packageName)
App.get().addUserDisallowedPackageName(packageName) debounceSave()
} }
fun unexclude(packageName: String) { fun unexclude(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName) 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)
}
} }
} }

Loading…
Cancel
Save