From c828572cc588608df57df9dfa94e55ebe54c02d7 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Wed, 26 Jun 2024 11:36:46 -0700 Subject: [PATCH] android: implement app split tunneling support Updates tailscale/tailscale#6912 Adds UI and models that provide the ability to add/remove apps which should be excluded from going through the VPN tunnel. Signed-off-by: Andrea Gottardo --- android/src/main/AndroidManifest.xml | 16 ++-- .../src/main/java/com/tailscale/ipn/App.kt | 84 ++++++++++++++++- .../main/java/com/tailscale/ipn/IPNService.kt | 40 ++++---- .../java/com/tailscale/ipn/MainActivity.kt | 3 + .../ipn/ui/util/InstalledAppsManager.kt | 34 +++++++ .../com/tailscale/ipn/ui/view/SettingsView.kt | 10 +- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 93 +++++++++++++++++++ .../ipn/ui/viewModel/SettingsViewModel.kt | 2 +- .../SplitTunnelAppPickerViewModel.kt | 40 ++++++++ android/src/main/res/values/strings.xml | 4 + 10 files changed, 294 insertions(+), 32 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index bde7ad944..56e75a225 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -9,8 +10,11 @@ android:maxSdkVersion="29" /> - - + + + + android:foregroundServiceType="systemExempted" + android:permission="android.permission.BIND_VPN_SERVICE"> diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 6af508cf7..7e8142609 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -343,6 +343,8 @@ class App : UninitializedApp(), libtailscale.AppContext { */ open class UninitializedApp : Application() { companion object { + const val TAG = "UninitializedApp" + const val STATUS_NOTIFICATION_ID = 1 const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_CHANNEL_ID = "tailscale-status" @@ -351,6 +353,8 @@ open class UninitializedApp : Application() { // the VPN (i.e. we're logged in and machine is authorized). private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" + private const val DISALLOWED_APPS_KEY = "disallowedApps" + // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" @@ -382,12 +386,34 @@ open class UninitializedApp : Application() { fun startVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } - startForegroundService(intent) + try { + startForegroundService(intent) + } catch (foregroundServiceStartException: IllegalStateException) { + Log.e( + TAG, + "startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException") + } catch (securityException: SecurityException) { + Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException") + } catch (e: Exception) { + Log.e(TAG, "startVPN hit exception in startForegroundService(): $e") + } } fun stopVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } - startService(intent) + try { + startService(intent) + } catch (illegalStateException: IllegalStateException) { + Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException") + } catch (e: Exception) { + Log.e(TAG, "stopVPN hit exception in startService(): $e") + } + } + + // Calls stopVPN() followed by startVPN() to restart the VPN. + fun restartVPN() { + stopVPN() + startVPN() } fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { @@ -451,4 +477,58 @@ open class UninitializedApp : Application() { .setContentIntent(pendingIntent) .build() } + + fun addUserDisallowedPackageName(packageName: String) { + if (packageName.isEmpty()) { + Log.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()) { + Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + return + } + + getUnencryptedPrefs() + .edit() + .putStringSet( + DISALLOWED_APPS_KEY, + disallowedPackageNames().toMutableSet().subtract(setOf(packageName))) + .apply() + + this.restartVPN() + } + + fun disallowedPackageNames(): List { + val userDisallowed = + getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() + return builtInDisallowedPackageNames + userDisallowed + } + + val builtInDisallowedPackageNames: List = + listOf( + // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 + "com.google.android.apps.messaging", + // Stadia https://github.com/tailscale/tailscale/issues/3460 + "com.google.stadia.android", + // Android Auto https://github.com/tailscale/tailscale/issues/3828 + "com.google.android.projection.gearhead", + // GoPro https://github.com/tailscale/tailscale/issues/2554 + "com.gopro.smarty", + // Sonos https://github.com/tailscale/tailscale/issues/2548 + "com.sonos.acr", + "com.sonos.acr2", + // Google Chromecast https://github.com/tailscale/tailscale/issues/3636 + "com.google.android.apps.chromecast.app", + ) } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 900483a4b..9dc744bec 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -8,10 +8,12 @@ import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.system.OsConstants +import android.util.Log import libtailscale.Libtailscale import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { + private val TAG = "IPNService" private val randomID: String = UUID.randomUUID().toString() override fun id(): String { @@ -76,9 +78,13 @@ open class IPNService : VpnService(), libtailscale.IPNService { } private fun showForegroundNotification() { - startForeground( - UninitializedApp.STATUS_NOTIFICATION_ID, - UninitializedApp.get().buildStatusNotification(true)) + try { + startForeground( + UninitializedApp.STATUS_NOTIFICATION_ID, + UninitializedApp.get().buildStatusNotification(true)) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service: $e") + } } private fun configIntent(): PendingIntent { @@ -92,7 +98,9 @@ open class IPNService : VpnService(), libtailscale.IPNService { private fun disallowApp(b: Builder, name: String) { try { b.addDisallowedApplication(name) - } catch (e: PackageManager.NameNotFoundException) {} + } catch (e: PackageManager.NameNotFoundException) { + Log.d(TAG, "Failed to add disallowed application: $e") + } } override fun newBuilder(): VPNServiceBuilder { @@ -106,24 +114,14 @@ open class IPNService : VpnService(), libtailscale.IPNService { } b.setUnderlyingNetworks(null) // Use all available networks. - // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 - disallowApp(b, "com.google.android.apps.messaging") - - // Stadia https://github.com/tailscale/tailscale/issues/3460 - disallowApp(b, "com.google.stadia.android") - - // Android Auto https://github.com/tailscale/tailscale/issues/3828 - disallowApp(b, "com.google.android.projection.gearhead") - - // GoPro https://github.com/tailscale/tailscale/issues/2554 - disallowApp(b, "com.gopro.smarty") - - // Sonos https://github.com/tailscale/tailscale/issues/2548 - disallowApp(b, "com.sonos.acr") - disallowApp(b, "com.sonos.acr2") + // Prevent certain apps from getting their traffic + DNS routed via Tailscale: + // - any app that the user manually disallowed in the GUI + // - any app that we disallowed via hard-coding + for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { + Log.d(TAG, "Disallowing app: $disallowedPackageName") + disallowApp(b, disallowedPackageName) + } - // Google Chromecast https://github.com/tailscale/tailscale/issues/3636 - disallowApp(b, "com.google.android.apps.chromecast.app") return VPNServiceBuilder(b) } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 307a2976a..b6c23b1ad 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -63,6 +63,7 @@ import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SettingsView +import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView @@ -162,6 +163,7 @@ class MainActivity : ComponentActivity() { onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToAbout = { navController.navigate("about") }, onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, + onNavigateToSplitTunneling = { navController.navigate("splitTunneling") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToManagedBy = { navController.navigate("managedBy") }, @@ -214,6 +216,7 @@ class MainActivity : ComponentActivity() { } composable("bugReport") { BugReportView(backTo("settings")) } composable("dnsSettings") { DNSSettingsView(backTo("settings")) } + composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) } composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) } composable("about") { AboutView(backTo("settings")) } composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt new file mode 100644 index 000000000..8abb3b622 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt @@ -0,0 +1,34 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager + +data class InstalledApp(val name: String, val packageName: String) + +class InstalledAppsManager( + val packageManager: PackageManager, +) { + fun fetchInstalledApps(): List { + return packageManager + .getInstalledApplications(PackageManager.GET_META_DATA) + .filter(appIsIncluded) + .map { + InstalledApp( + name = it.loadLabel(packageManager).toString(), + packageName = it.packageName, + ) + } + .sortedBy { it.name } + } + + private val appIsIncluded: (ApplicationInfo) -> Boolean = { app -> + app.packageName != "com.tailscale.ipn" && + // Only show apps that can access the Internet + packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) == + PackageManager.PERMISSION_GRANTED + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 70b660db0..340867e84 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -55,7 +55,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) }) { innerPadding -> Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { - if (isVPNPrepared){ + if (isVPNPrepared) { UserView( profile = user, actionState = UserActionState.NAV, @@ -77,6 +77,12 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo }, onClick = settingsNav.onNavigateToDNSSettings) + Lists.ItemDivider() + Setting.Text( + R.string.split_tunneling, + subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + onClick = settingsNav.onNavigateToSplitTunneling) + if (showTailnetLock == ShowHide.Show) { Lists.ItemDivider() Setting.Text( @@ -199,5 +205,5 @@ fun SettingsPreview() { vm.tailNetLockEnabled.set(true) vm.isAdmin.set(true) vm.managedByOrganization.set("Tails and Scales Inc.") - SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) + SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt new file mode 100644 index 000000000..a087d6499 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.App +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel + +@Composable +fun SplitTunnelAppPickerView( + backToSettings: BackNavigation, + model: SplitTunnelAppPickerViewModel = viewModel() +) { + val installedApps by model.installedApps.collectAsState() + val excludedPackageNames by model.excludedPackageNames.collectAsState() + val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames + + Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { + innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item(key = "header") { + ListItem( + headlineContent = { + Text( + stringResource( + R.string + .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + }) + } + item("resolversHeader") { + Lists.SectionDivider( + stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) + } + items(installedApps) { app -> + ListItem( + headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, + leadingContent = { + Image( + bitmap = + model.installedAppsManager.packageManager + .getApplicationIcon(app.packageName) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier.width(40.dp).height(40.dp)) + }, + supportingContent = { + Text( + app.packageName, + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing) + }, + trailingContent = { + Checkbox( + checked = excludedPackageNames.contains(app.packageName), + enabled = !builtInDisallowedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.exclude(packageName = app.packageName) + } else { + model.unexclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 7d64fbd34..51aaf8bf0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -4,7 +4,6 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.viewModelScope -import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.LoadingIndicator @@ -17,6 +16,7 @@ data class SettingsNav( val onNavigateToBugReport: () -> Unit, val onNavigateToAbout: () -> Unit, val onNavigateToDNSSettings: () -> Unit, + val onNavigateToSplitTunneling: () -> Unit, val onNavigateToTailnetLock: () -> Unit, val onNavigateToMDMSettings: () -> Unit, val onNavigateToManagedBy: () -> Unit, 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 new file mode 100644 index 000000000..2cfe337fc --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -0,0 +1,40 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.tailscale.ipn.App +import com.tailscale.ipn.ui.util.InstalledApp +import com.tailscale.ipn.ui.util.InstalledAppsManager +import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SplitTunnelAppPickerViewModel : ViewModel() { + val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) + val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val installedApps: StateFlow> = MutableStateFlow(listOf()) + + init { + installedApps.set(installedAppsManager.fetchInstalledApps()) + excludedPackageNames.set( + App.get() + .disallowedPackageNames() + .intersect(installedApps.value.map { it.packageName }) + .toList()) + } + + fun exclude(packageName: String) { + if (excludedPackageNames.value.contains(packageName)) { + return + } + excludedPackageNames.set(excludedPackageNames.value + packageName) + App.get().addUserDisallowedPackageName(packageName) + } + + fun unexclude(packageName: String) { + excludedPackageNames.set(excludedPackageNames.value - packageName) + App.get().removeUserDisallowedPackageName(packageName) + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5e05a0762..a50b6e97c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -273,4 +273,8 @@ Ping failed An unknown error occurred. Please try again. Request timed out. Make sure that \'%1$s\' is online. + App split tunneling + Exclude certain apps from using Tailscale + Apps selected here will access the Internet directly, without using Tailscale. + Excluded apps (%1$s)