From 69d2ff1f9ed93e93a5587e382d796bca591e990b Mon Sep 17 00:00:00 2001 From: Danial Date: Mon, 8 Dec 2025 04:12:29 -0800 Subject: [PATCH 1/4] Implemented Experimental split tunnelling support. (#5) Implemented Experimental split tunnelling support. This adds a prototype "include-mode" routing model for Android VPN configuration, addressing long-standing user requests for selectively tunneling only chosen applications. When enabled, the VPN builder whitelists specific package names rather than excluding all others. This avoids the need to maintain long exclude lists and supports targeted routing (e.g., only browsers, music players, etc). Details: Implemented a UI toggle button which turns on split tunneling, and which then presents the option to either include or exclude app packages in the VPNBuilder, without having to use MDM. *When Split tunnelling is disabled, the app excludes all packages in builtInDisallowedPackageNames by default, mirroring existing default functionality when no packages are manually added to the exclude list* If Included or Excluded packages are included by MDM, the split tunneling toggle is vacuously turned on -- and user include/exclude lists are ignored. Limitations: DNS resolver traffic can fail under include-mode when Tailscale DNS is active (different results observed across 3 devices). Disabling Tailscale DNS appears to mitigate the issue, but the underlying resolver interaction remains open for analysis. Feedback, testing results, and alternative approaches from developers or knowledgeable users are welcome and appreciated. This commit is intended as a discussion basis / RFC for upstream consideration and iteration :) --------- Signed-off-by: Danial Ramzan Signed-off-by: danialramzan --- .../src/main/java/com/tailscale/ipn/App.kt | 71 +++++- .../main/java/com/tailscale/ipn/IPNService.kt | 105 +++++--- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 1 + .../com/tailscale/ipn/ui/view/SettingsView.kt | 2 +- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 240 +++++++++++++----- .../SplitTunnelAppPickerViewModel.kt | 51 +++- android/src/main/res/values/strings.xml | 6 +- 7 files changed, 380 insertions(+), 96 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 481e85de79..3514ec7457 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -426,7 +426,11 @@ 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 ALLOWED_APPS_KEY = "allowedApps" + private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled" + private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode" + + // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat @@ -599,19 +603,73 @@ open class UninitializedApp : Application() { this.restartVPN() } - fun disallowedPackageNames(): List { + fun updateUserAllowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)") + return + } + getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply() + this.restartVPN() + } + + fun isSplitTunnelEnabled(): Boolean = + getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false) + + fun setSplitTunnelEnabled(enabled: Boolean) { + getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply() + restartVPN() + } + + fun setSplitTunnelMode(mode: SplitTunnelMode) { + getUnencryptedPrefs().edit() + .putString(SPLIT_TUNNEL_MODE_KEY, mode.name) + .apply() + restartVPN() + } + + fun getSplitTunnelMode(): SplitTunnelMode { + val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null) + return if (stored != null) { + SplitTunnelMode.valueOf(stored) + } else { + SplitTunnelMode.EXCLUDE + } + } + + + + + fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (mdmDisallowed.isNotEmpty()) { + + if (mdmDisallowed.isNotEmpty()) { TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") return builtInDisallowedPackageNames + mdmDisallowed } + val userDisallowed = getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() return builtInDisallowedPackageNames + userDisallowed } - fun getAppScopedViewModel(): AppViewModel { + + fun allowedPackageNames(): List { + val mdmAllowed = + MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() + + if (mdmAllowed.isNotEmpty()) { + TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed") + return mdmAllowed + } + + val userAllowed = + getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() + return userAllowed + } + + + fun getAppScopedViewModel(): AppViewModel { return appViewModel } @@ -640,4 +698,9 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) + + enum class SplitTunnelMode { + INCLUDE, + EXCLUDE + } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index e6eb995d71..f90bacb702 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService { } } - override fun newBuilder(): VPNServiceBuilder { - val b: Builder = - Builder() - .setConfigureIntent(configIntent()) - .allowFamily(OsConstants.AF_INET) - .allowFamily(OsConstants.AF_INET6) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - b.setMetered(false) // Inherit the metered status from the underlying networks. - } - b.setUnderlyingNetworks(null) // Use all available networks. - - val includedPackages: List = - MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (includedPackages.isNotEmpty()) { - // If an admin defined a list of packages that are exclusively allowed to be used via - // Tailscale, - // then only allow those apps. - for (packageName in includedPackages) { - TSLog.d(TAG, "Including app: $packageName") - b.addAllowedApplication(packageName) - } - } else { - // Otherwise, 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()) { - TSLog.d(TAG, "Disallowing app: $disallowedPackageName") - disallowApp(b, disallowedPackageName) - } + private fun allowApp(b: Builder, name: String) { + try { + b.addAllowedApplication(name) + } catch (e: PackageManager.NameNotFoundException) { + TSLog.d(TAG, "Failed to add allowed application: $e") + } } - return VPNServiceBuilder(b) - } + override fun newBuilder(): VPNServiceBuilder { + val b: Builder = + Builder() + .setConfigureIntent(configIntent()) + .allowFamily(OsConstants.AF_INET) + .allowFamily(OsConstants.AF_INET6) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + b.setMetered(false) // Inherit the metered status from the underlying networks. + } + b.setUnderlyingNetworks(null) // Use all available networks. + + val app = UninitializedApp.get() + + val mdmAllowed = MDMSettings.includedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + + val splitEnabled = app.isSplitTunnelEnabled() + val mode = app.getSplitTunnelMode() + + when { + mdmAllowed.isNotEmpty() -> { + TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed") + mdmAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via MDM: $pkg") + allowApp(b, pkg) + } + } + + mdmDisallowed.isNotEmpty() -> { + val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed + TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via MDM: $pkg") + disallowApp(b, pkg) + } + } + + !splitEnabled -> { + TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only") + app.builtInDisallowedPackageNames.forEach { pkg -> + TSLog.d(TAG, "Disallowing built-in app: $pkg") + disallowApp(b, pkg) + } + } + + mode == UninitializedApp.SplitTunnelMode.INCLUDE -> { + val userAllowed = app.allowedPackageNames() + TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed") + userAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via user INCLUDE: $pkg") + allowApp(b, pkg) + } + } + + else -> { + val effectiveDisallowed = app.disallowedPackageNames() + TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg") + disallowApp(b, pkg) + } + } + } + + return VPNServiceBuilder(b) + } companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 38acc7fac9..bc8213bd99 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -120,6 +120,7 @@ class Ipn { CorpDNSSet = true } + var ExitNodeID: StableNodeID? = null set(value) { field = value 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 2dc187fc26..c368eb8f11 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 @@ -89,7 +89,7 @@ fun SettingsView( Lists.ItemDivider() Setting.Text( R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale), onClick = settingsNav.onNavigateToSplitTunneling) if (showTailnetLock.value == ShowHide.Show) { 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 index 27b18c5dac..031776569b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -4,12 +4,15 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.FilterChip import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -17,6 +20,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource @@ -26,6 +31,7 @@ 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.UninitializedApp.SplitTunnelMode import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel @@ -36,76 +42,196 @@ fun SplitTunnelAppPickerView( ) { val installedApps by model.installedApps.collectAsState() val excludedPackageNames by model.excludedPackageNames.collectAsState() - val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames + val includedPackageNames by model.includedPackageNames.collectAsState() + + + val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() - Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { + val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())} + val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())} + + + Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { item(key = "header") { - ListItem( + + val mdmActive = + (mdmExcludedPackages.value?.isNotEmpty() == true) || + (mdmIncludedPackages.value?.isNotEmpty() == true) + + if (mdmActive) { + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = true, + enabled = false, + onToggle = {} + ) + } else { + + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = splitEnabled.value, + onToggle = { + val newVal = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newVal) + splitEnabled.value = App.get().isSplitTunnelEnabled() + } + ) + } + + ListItem( headlineContent = { Text( stringResource( R.string - .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + .selected_apps_will_follow_custom_routing)) }) + } - if (mdmExcludedPackages.value?.isNotEmpty() == true) { - item("mdmExcludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) - }) - } - } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { - item("mdmIncludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) - }) - } - } else { - 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) - } + + + if (mdmExcludedPackages.value?.isNotEmpty() == true) { + item("mdmExcludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) }) - }) - Lists.ItemDivider() + } + } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { + item("mdmIncludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) + }) + } + } else { + if (splitEnabled.value) { + + item("resolversHeader") { + Row(modifier = Modifier.padding(16.dp)) { + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.EXCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Exclude apps") } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.INCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.INCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Include apps") } + ) + } + } + + + + if (currentSplitMode.value == SplitTunnelMode.EXCLUDE) { + item("resolversHeaderExclude") { + 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() + } + } else { + + item("resolversHeaderInclude") { + Lists.SectionDivider( + stringResource( + R.string.count_included_apps, + includedPackageNames.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 = includedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.include(packageName = app.packageName) + } else { + model.uninclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + } + + + + } + } } - } } } -} +} \ No newline at end of file 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 7611f0516e..4ebb2a3166 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 @@ -20,12 +20,16 @@ import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val includedPackageNames: StateFlow> = MutableStateFlow(listOf()) val installedApps: StateFlow> = MutableStateFlow(listOf()) val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow private var saveJob: Job? = null + + + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -33,6 +37,12 @@ class SplitTunnelAppPickerViewModel : ViewModel() { .disallowedPackageNames() .intersect(installedApps.value.map { it.packageName }.toSet()) .toList()) + includedPackageNames.set( + App.get() + .allowedPackageNames() + .intersect(installedApps.value.map { it.packageName }.toSet()) + .toList()) + } fun exclude(packageName: String) { @@ -46,7 +56,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() { debounceSave() } - private fun debounceSave() { + fun include(packageName: String) { + if (includedPackageNames.value.contains(packageName)) return + includedPackageNames.set(includedPackageNames.value + packageName) + debounceSaveInclude() + } + + fun uninclude(packageName: String) { + includedPackageNames.set(includedPackageNames.value - packageName) + debounceSaveInclude() + } + + private fun debounceSave() { saveJob?.cancel() saveJob = viewModelScope.launch { @@ -54,4 +75,32 @@ class SplitTunnelAppPickerViewModel : ViewModel() { App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) } } + + private fun debounceSaveInclude() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) + App.get().updateUserAllowedPackageNames(includedPackageNames.value) + } + } + + + fun toggleSplitTunnel() { + val newValue = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newValue) + } + + // If MDM inforces split tunnel — write it to sharedprefs + private fun enforceMdMSplitTunnel() { + val mdmActive = + mdmExcludedPackages.value.value?.isNotEmpty() == true || + mdmIncludedPackages.value.value?.isNotEmpty() == true + + if (mdmActive && !App.get().isSplitTunnelEnabled()) { + App.get().setSplitTunnelEnabled(true) + } + } + + } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 3e3db0512c..9e3f2f0dc9 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -309,9 +309,11 @@ 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. + Enable split tunneling + Include or exclude certain apps from using Tailscale + Apps selected here will follow special routing depending on your split-tunnel configuration. Excluded apps (%1$s) + Included apps (%1$s) Certain apps are not routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Only specific apps are routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Specifies a list of apps that will be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale. From c9346412e6741db4058797c429f87c3ce6e59100 Mon Sep 17 00:00:00 2001 From: Danial Ramzan Date: Mon, 8 Dec 2025 07:12:05 -0800 Subject: [PATCH 2/4] Implemented search bar in split-tunneling page. Signed-off-by: Danial Ramzan Signed-off-by: danialramzan --- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 109 +++++++++++++++++- android/src/main/res/values/strings.xml | 1 + 2 files changed, 106 insertions(+), 4 deletions(-) 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 index 031776569b..341778961e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -4,18 +4,29 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth 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.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -51,6 +62,15 @@ fun SplitTunnelAppPickerView( val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())} val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())} + val searchQuery = remember { mutableStateOf("") } + + val filteredApps = installedApps.filter { app -> + searchQuery.value.isBlank() || + app.name.contains(searchQuery.value, ignoreCase = true) || + app.packageName.contains(searchQuery.value, ignoreCase = true) + } + + Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { @@ -111,7 +131,16 @@ fun SplitTunnelAppPickerView( if (splitEnabled.value) { item("resolversHeader") { - Row(modifier = Modifier.padding(16.dp)) { + + Spacer(modifier = Modifier.height(8.dp)) + + + AppSearchBar( + query = searchQuery.value, + onQueryChange = { searchQuery.value = it } + ) + + Row(modifier = Modifier.padding(horizontal = 8.dp)) { FilterChip( selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE, onClick = { @@ -123,6 +152,7 @@ fun SplitTunnelAppPickerView( Spacer(modifier = Modifier.width(8.dp)) + FilterChip( selected = currentSplitMode.value == SplitTunnelMode.INCLUDE, onClick = { @@ -145,7 +175,24 @@ fun SplitTunnelAppPickerView( ) ) } - items(installedApps) { app -> + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(filteredApps) { app -> ListItem( headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, leadingContent = { @@ -191,7 +238,25 @@ fun SplitTunnelAppPickerView( ) ) } - items(installedApps) { app -> + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + + items(filteredApps) { app -> ListItem( headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, leadingContent = { @@ -225,6 +290,8 @@ fun SplitTunnelAppPickerView( }) }) Lists.ItemDivider() + + } @@ -234,4 +301,38 @@ fun SplitTunnelAppPickerView( } } } -} \ No newline at end of file +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSearchBar( + query: String, + onQueryChange: (String) -> Unit +) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + value = query, + onValueChange = onQueryChange, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + }, + shape = SearchBarDefaults.dockedShape, + placeholder = { Text(stringResource(R.string.search_apps_ellipsis)) }, + singleLine = true, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + } + ) +} + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 9e3f2f0dc9..3d00045c18 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Warning Search Search... + Search apps... Dismiss No results Back From 744e840cb4b28848f5ca3a74c73ee1300fbaa905 Mon Sep 17 00:00:00 2001 From: Danial Ramzan Date: Mon, 8 Dec 2025 09:07:03 -0800 Subject: [PATCH 3/4] pull request to main (#6) * Implemented Experimental split tunnelling support. (#5) Implemented Experimental split tunnelling support. This adds a prototype "include-mode" routing model for Android VPN configuration, addressing long-standing user requests for selectively tunneling only chosen applications. When enabled, the VPN builder whitelists specific package names rather than excluding all others. This avoids the need to maintain long exclude lists and supports targeted routing (e.g., only browsers, music players, etc). Details: Implemented a UI toggle button which turns on split tunneling, and which then presents the option to either include or exclude app packages in the VPNBuilder, without having to use MDM. *When Split tunnelling is disabled, the app excludes all packages in builtInDisallowedPackageNames by default, mirroring existing default functionality when no packages are manually added to the exclude list* If Included or Excluded packages are included by MDM, the split tunneling toggle is vacuously turned on -- and user include/exclude lists are ignored. Limitations: DNS resolver traffic can fail under include-mode when Tailscale DNS is active (different results observed across 3 devices). Disabling Tailscale DNS appears to mitigate the issue, but the underlying resolver interaction remains open for analysis. Feedback, testing results, and alternative approaches from developers or knowledgeable users are welcome and appreciated. This commit is intended as a discussion basis / RFC for upstream consideration and iteration :) --------- Signed-off-by: Danial Ramzan * Implemented search bar in split-tunneling page. Signed-off-by: Danial Ramzan --------- Signed-off-by: Danial Ramzan --- .../src/main/java/com/tailscale/ipn/App.kt | 71 +++- .../main/java/com/tailscale/ipn/IPNService.kt | 105 ++++-- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 1 + .../com/tailscale/ipn/ui/view/SettingsView.kt | 2 +- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 339 +++++++++++++++--- .../SplitTunnelAppPickerViewModel.kt | 51 ++- android/src/main/res/values/strings.xml | 7 +- 7 files changed, 481 insertions(+), 95 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 481e85de79..3514ec7457 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -426,7 +426,11 @@ 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 ALLOWED_APPS_KEY = "allowedApps" + private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled" + private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode" + + // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat @@ -599,19 +603,73 @@ open class UninitializedApp : Application() { this.restartVPN() } - fun disallowedPackageNames(): List { + fun updateUserAllowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)") + return + } + getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply() + this.restartVPN() + } + + fun isSplitTunnelEnabled(): Boolean = + getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false) + + fun setSplitTunnelEnabled(enabled: Boolean) { + getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply() + restartVPN() + } + + fun setSplitTunnelMode(mode: SplitTunnelMode) { + getUnencryptedPrefs().edit() + .putString(SPLIT_TUNNEL_MODE_KEY, mode.name) + .apply() + restartVPN() + } + + fun getSplitTunnelMode(): SplitTunnelMode { + val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null) + return if (stored != null) { + SplitTunnelMode.valueOf(stored) + } else { + SplitTunnelMode.EXCLUDE + } + } + + + + + fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (mdmDisallowed.isNotEmpty()) { + + if (mdmDisallowed.isNotEmpty()) { TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") return builtInDisallowedPackageNames + mdmDisallowed } + val userDisallowed = getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() return builtInDisallowedPackageNames + userDisallowed } - fun getAppScopedViewModel(): AppViewModel { + + fun allowedPackageNames(): List { + val mdmAllowed = + MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() + + if (mdmAllowed.isNotEmpty()) { + TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed") + return mdmAllowed + } + + val userAllowed = + getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() + return userAllowed + } + + + fun getAppScopedViewModel(): AppViewModel { return appViewModel } @@ -640,4 +698,9 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) + + enum class SplitTunnelMode { + INCLUDE, + EXCLUDE + } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index e6eb995d71..f90bacb702 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService { } } - override fun newBuilder(): VPNServiceBuilder { - val b: Builder = - Builder() - .setConfigureIntent(configIntent()) - .allowFamily(OsConstants.AF_INET) - .allowFamily(OsConstants.AF_INET6) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - b.setMetered(false) // Inherit the metered status from the underlying networks. - } - b.setUnderlyingNetworks(null) // Use all available networks. - - val includedPackages: List = - MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (includedPackages.isNotEmpty()) { - // If an admin defined a list of packages that are exclusively allowed to be used via - // Tailscale, - // then only allow those apps. - for (packageName in includedPackages) { - TSLog.d(TAG, "Including app: $packageName") - b.addAllowedApplication(packageName) - } - } else { - // Otherwise, 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()) { - TSLog.d(TAG, "Disallowing app: $disallowedPackageName") - disallowApp(b, disallowedPackageName) - } + private fun allowApp(b: Builder, name: String) { + try { + b.addAllowedApplication(name) + } catch (e: PackageManager.NameNotFoundException) { + TSLog.d(TAG, "Failed to add allowed application: $e") + } } - return VPNServiceBuilder(b) - } + override fun newBuilder(): VPNServiceBuilder { + val b: Builder = + Builder() + .setConfigureIntent(configIntent()) + .allowFamily(OsConstants.AF_INET) + .allowFamily(OsConstants.AF_INET6) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + b.setMetered(false) // Inherit the metered status from the underlying networks. + } + b.setUnderlyingNetworks(null) // Use all available networks. + + val app = UninitializedApp.get() + + val mdmAllowed = MDMSettings.includedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + + val splitEnabled = app.isSplitTunnelEnabled() + val mode = app.getSplitTunnelMode() + + when { + mdmAllowed.isNotEmpty() -> { + TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed") + mdmAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via MDM: $pkg") + allowApp(b, pkg) + } + } + + mdmDisallowed.isNotEmpty() -> { + val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed + TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via MDM: $pkg") + disallowApp(b, pkg) + } + } + + !splitEnabled -> { + TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only") + app.builtInDisallowedPackageNames.forEach { pkg -> + TSLog.d(TAG, "Disallowing built-in app: $pkg") + disallowApp(b, pkg) + } + } + + mode == UninitializedApp.SplitTunnelMode.INCLUDE -> { + val userAllowed = app.allowedPackageNames() + TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed") + userAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via user INCLUDE: $pkg") + allowApp(b, pkg) + } + } + + else -> { + val effectiveDisallowed = app.disallowedPackageNames() + TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg") + disallowApp(b, pkg) + } + } + } + + return VPNServiceBuilder(b) + } companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 38acc7fac9..bc8213bd99 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -120,6 +120,7 @@ class Ipn { CorpDNSSet = true } + var ExitNodeID: StableNodeID? = null set(value) { field = value 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 2dc187fc26..c368eb8f11 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 @@ -89,7 +89,7 @@ fun SettingsView( Lists.ItemDivider() Setting.Text( R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale), onClick = settingsNav.onNavigateToSplitTunneling) if (showTailnetLock.value == ShowHide.Show) { 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 index 27b18c5dac..341778961e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -4,19 +4,35 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth 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.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource @@ -26,6 +42,7 @@ 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.UninitializedApp.SplitTunnelMode import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel @@ -36,76 +53,286 @@ fun SplitTunnelAppPickerView( ) { val installedApps by model.installedApps.collectAsState() val excludedPackageNames by model.excludedPackageNames.collectAsState() - val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames + val includedPackageNames by model.includedPackageNames.collectAsState() + + + val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() - Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { + val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())} + val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())} + val searchQuery = remember { mutableStateOf("") } + + val filteredApps = installedApps.filter { app -> + searchQuery.value.isBlank() || + app.name.contains(searchQuery.value, ignoreCase = true) || + app.packageName.contains(searchQuery.value, ignoreCase = true) + } + + + + + Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { item(key = "header") { - ListItem( + + val mdmActive = + (mdmExcludedPackages.value?.isNotEmpty() == true) || + (mdmIncludedPackages.value?.isNotEmpty() == true) + + if (mdmActive) { + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = true, + enabled = false, + onToggle = {} + ) + } else { + + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = splitEnabled.value, + onToggle = { + val newVal = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newVal) + splitEnabled.value = App.get().isSplitTunnelEnabled() + } + ) + } + + ListItem( headlineContent = { Text( stringResource( R.string - .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + .selected_apps_will_follow_custom_routing)) }) + } - if (mdmExcludedPackages.value?.isNotEmpty() == true) { - item("mdmExcludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) - }) - } - } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { - item("mdmIncludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) - }) - } - } else { - 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) - } + + + if (mdmExcludedPackages.value?.isNotEmpty() == true) { + item("mdmExcludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) + }) + } + } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { + item("mdmIncludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) }) - }) - Lists.ItemDivider() + } + } else { + if (splitEnabled.value) { + + item("resolversHeader") { + + Spacer(modifier = Modifier.height(8.dp)) + + + AppSearchBar( + query = searchQuery.value, + onQueryChange = { searchQuery.value = it } + ) + + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.EXCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Exclude apps") } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.INCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.INCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Include apps") } + ) + } + } + + + + if (currentSplitMode.value == SplitTunnelMode.EXCLUDE) { + item("resolversHeaderExclude") { + Lists.SectionDivider( + stringResource( + R.string.count_excluded_apps, + excludedPackageNames.count() + ) + ) + } + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(filteredApps) { 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() + } + } else { + + item("resolversHeaderInclude") { + Lists.SectionDivider( + stringResource( + R.string.count_included_apps, + includedPackageNames.count() + ) + ) + } + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + + items(filteredApps) { 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 = includedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.include(packageName = app.packageName) + } else { + model.uninclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + + + } + + + + } + } } - } } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSearchBar( + query: String, + onQueryChange: (String) -> Unit +) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + value = query, + onValueChange = onQueryChange, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + }, + shape = SearchBarDefaults.dockedShape, + placeholder = { Text(stringResource(R.string.search_apps_ellipsis)) }, + singleLine = true, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + } + ) +} + + 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 7611f0516e..4ebb2a3166 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 @@ -20,12 +20,16 @@ import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val includedPackageNames: StateFlow> = MutableStateFlow(listOf()) val installedApps: StateFlow> = MutableStateFlow(listOf()) val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow private var saveJob: Job? = null + + + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -33,6 +37,12 @@ class SplitTunnelAppPickerViewModel : ViewModel() { .disallowedPackageNames() .intersect(installedApps.value.map { it.packageName }.toSet()) .toList()) + includedPackageNames.set( + App.get() + .allowedPackageNames() + .intersect(installedApps.value.map { it.packageName }.toSet()) + .toList()) + } fun exclude(packageName: String) { @@ -46,7 +56,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() { debounceSave() } - private fun debounceSave() { + fun include(packageName: String) { + if (includedPackageNames.value.contains(packageName)) return + includedPackageNames.set(includedPackageNames.value + packageName) + debounceSaveInclude() + } + + fun uninclude(packageName: String) { + includedPackageNames.set(includedPackageNames.value - packageName) + debounceSaveInclude() + } + + private fun debounceSave() { saveJob?.cancel() saveJob = viewModelScope.launch { @@ -54,4 +75,32 @@ class SplitTunnelAppPickerViewModel : ViewModel() { App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) } } + + private fun debounceSaveInclude() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) + App.get().updateUserAllowedPackageNames(includedPackageNames.value) + } + } + + + fun toggleSplitTunnel() { + val newValue = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newValue) + } + + // If MDM inforces split tunnel — write it to sharedprefs + private fun enforceMdMSplitTunnel() { + val mdmActive = + mdmExcludedPackages.value.value?.isNotEmpty() == true || + mdmIncludedPackages.value.value?.isNotEmpty() == true + + if (mdmActive && !App.get().isSplitTunnelEnabled()) { + App.get().setSplitTunnelEnabled(true) + } + } + + } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 3e3db0512c..3d00045c18 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Warning Search Search... + Search apps... Dismiss No results Back @@ -309,9 +310,11 @@ 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. + Enable split tunneling + Include or exclude certain apps from using Tailscale + Apps selected here will follow special routing depending on your split-tunnel configuration. Excluded apps (%1$s) + Included apps (%1$s) Certain apps are not routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Only specific apps are routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Specifies a list of apps that will be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale. From 70adf2fe84996d7badd09b7c00c980ae157b4b98 Mon Sep 17 00:00:00 2001 From: danialramzan Date: Mon, 8 Dec 2025 09:25:29 -0800 Subject: [PATCH 4/4] Fixes https://github.com/tailscale/tailscale/issues/13816 Signed-off-by: danialramzan --- .../java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt | 1 + 1 file changed, 1 insertion(+) 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 index 341778961e..7487c5cd3c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -82,6 +82,7 @@ fun SplitTunnelAppPickerView( (mdmExcludedPackages.value?.isNotEmpty() == true) || (mdmIncludedPackages.value?.isNotEmpty() == true) + if (mdmActive) { Setting.Switch( R.string.split_tunneling_enabled,