Skip to content

android: add selection of included/excluded apps in split tunneling #621

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,13 @@ 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"
// The value is 'disallowedApps' as it used to represent
// only disallowed applications. This has been changed
// and allowing/disallowing is based on ALLOW_SELECTED_APPS_KEY
//
// The value is kept the same to not reset everyone's configuration
private const val SELECTED_APPS_KEY = "disallowedApps"
private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps"

// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
Expand Down Expand Up @@ -569,47 +575,53 @@ open class UninitializedApp : Application() {
return builder.build()
}

fun addUserDisallowedPackageName(packageName: String) {
fun addUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
TSLog.e(TAG, "addUserSelectedPackage called with empty packageName")
return
}

getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()

this.restartVPN()
}

fun removeUserDisallowedPackageName(packageName: String) {
fun removeUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
TSLog.e(TAG, "removeUserSelectedPackage called with empty packageName")
return
}

getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()

this.restartVPN()
}

fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
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 switchUserSelectedPackages() {
getUnencryptedPrefs()
.edit()
.putBoolean(ALLOW_SELECTED_APPS_KEY, !allowSelectedPackages())
.apply()
getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, setOf()).apply()

this.restartVPN()
}

fun selectedPackageNames(): List<String> {
return getUnencryptedPrefs().getStringSet(SELECTED_APPS_KEY, emptySet())?.toList()
?: emptyList()
}

fun allowSelectedPackages(): Boolean {
return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false)
}

fun getAppScopedViewModel(): VpnViewModel {
Expand Down
62 changes: 48 additions & 14 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,19 @@ open class IPNService : VpnService(), libtailscale.IPNService {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}

private fun allowApp(b: Builder, name: String) {
try {
b.addAllowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.e(TAG, "Failed to add allowed application: $e")
}
}

private fun disallowApp(b: Builder, name: String) {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add disallowed application: $e")
TSLog.e(TAG, "Failed to add disallowed application: $e")
}
}

Expand All @@ -154,23 +162,49 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
b.setUnderlyingNetworks(null) // Use all available networks.

val includedPackages: List<String> =
val mdmAllowed =
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) {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()

var packagesList: List<String>
var allowPackages: Boolean
if (mdmAllowed.isNotEmpty()) {
// An admin defined a list of packages that are exclusively allowed to be used via
// Tailscale, so only allow those.
packagesList = mdmAllowed
allowPackages = true
TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed")
} else if (mdmDisallowed.isNotEmpty()) {
// An admin defined a list of packages that are excluded from accessing Tailscale,
// so ignore user definitions and only exclude those
packagesList = mdmDisallowed
allowPackages = false
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
} else {
// Otherwise, prevent user manually disallowed apps from getting their traffic + DNS routed
// via Tailscale
packagesList = UninitializedApp.get().selectedPackageNames()
allowPackages = UninitializedApp.get().allowSelectedPackages()
TSLog.d(TAG, "Application packages were set by user: $packagesList")
}

if (allowPackages) {
// There always needs to be at least one allowed application for the VPN service to filter the
// traffic so add our own application by default to fulfill that requirement
packagesList += BuildConfig.APPLICATION_ID

for (packageName in packagesList) {
TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
allowApp(b, 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)
// Make sure to also exclude hard-coded apps that are known to cause issues
packagesList += UninitializedApp.get().builtInDisallowedPackageNames

for (packageName in packagesList) {
TSLog.d(TAG, "Disallowing app: $packageName")
disallowApp(b, packageName)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.util
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import com.tailscale.ipn.BuildConfig

data class InstalledApp(val name: String, val packageName: String)

Expand All @@ -26,7 +27,7 @@ class InstalledAppsManager(
}

private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
app.packageName != "com.tailscale.ipn" &&
app.packageName != BuildConfig.APPLICATION_ID &&
// Only show apps that can access the Internet
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
PackageManager.PERMISSION_GRANTED
Expand Down
24 changes: 24 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.warningButton

@Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Expand All @@ -26,6 +28,28 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() ->
content = content)
}

@Composable
fun WarningActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content,
colors = MaterialTheme.colorScheme.warningButton,
)
}

@Composable
fun DismissActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content,
colors = MaterialTheme.colorScheme.secondaryButton,
)
}

@Composable
fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.filter_apps_allowed_to_access_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)

if (showTailnetLock.value == ShowHide.Show) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@
package com.tailscale.ipn.ui.view

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
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.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
Expand All @@ -27,6 +35,7 @@ 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.util.set
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel

@Composable
Expand All @@ -35,23 +44,39 @@ fun SplitTunnelAppPickerView(
model: SplitTunnelAppPickerViewModel = viewModel()
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val selectedPackageNames by model.selectedPackageNames.collectAsState()
val allowSelected by model.allowSelected.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
val showHeaderMenu by model.showHeaderMenu.collectAsState()
val showSwitchDialog by model.showSwitchDialog.collectAsState()

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))
if (showSwitchDialog) {
SwitchAlertDialog(
onConfirm = {
model.showSwitchDialog.set(false)
model.performSelectionSwitch()
},
Comment on lines +57 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SplitTunnelAppPickerView.kt, the dialog implementation (SwitchAlertDialog) uses a nested function call pattern that could be simplified. The onConfirm callback calls model.showSwitchDialog.set(false) followed by model.performSelectionSwitch(). Consider handling this in the ViewModel to maintain separation of concerns.

onDismiss = { model.showSwitchDialog.set(false) })
}

Scaffold(
topBar = {
Header(
titleRes = R.string.split_tunneling,
onBack = backToSettings,
actions = {
Row {
FusMenu(viewModel = model, onSwitchClick = { model.showSwitchDialog.set(true) })
IconButton(onClick = { model.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
Comment on lines +72 to +73

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SplitTunnelAppPickerView.kt line 72-73, the IconButton has meaningful functionality but its icon's contentDescription is just 'menu' which is too generic. Consider using a more descriptive content description like 'Split tunneling options' to improve accessibility.

}
}
})
}
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
Expand All @@ -67,9 +92,22 @@ fun SplitTunnelAppPickerView(
})
}
} else {
item("header") {
ListItem(
headlineContent = {
Text(
stringResource(
if (allowSelected) R.string.selected_apps_will_access_tailscale
else
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
})
}
item("resolversHeader") {
Lists.SectionDivider(
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
stringResource(
if (allowSelected) R.string.count_included_apps else R.string.count_excluded_apps,
selectedPackageNames.count()))
}
items(installedApps) { app ->
ListItem(
Expand All @@ -93,13 +131,13 @@ fun SplitTunnelAppPickerView(
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
checked = selectedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
model.select(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
model.deselect(packageName = app.packageName)
}
})
})
Expand All @@ -109,3 +147,40 @@ fun SplitTunnelAppPickerView(
}
}
}

@Composable
fun FusMenu(viewModel: SplitTunnelAppPickerViewModel, onSwitchClick: (() -> Unit)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FusMenu name in SplitTunnelAppPickerView.kt is not descriptive of its purpose. Consider renaming it to something more meaningful like 'FilterSelectionMenu' to better indicate its role in switching between app inclusion and exclusion modes.

val expanded by viewModel.showHeaderMenu.collectAsState()
val allowSelected by viewModel.allowSelected.collectAsState()

DropdownMenu(
expanded = expanded,
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
MenuItem(
onClick = {
viewModel.showHeaderMenu.set(false)
onSwitchClick()
},
text =
stringResource(
if (allowSelected) R.string.switch_to_select_to_exclude
else R.string.switch_to_select_to_include))
}
}

@Composable
fun SwitchAlertDialog(onConfirm: (() -> Unit), onDismiss: (() -> Unit)) {
AlertDialog(
title = { Text(text = stringResource(R.string.switch_warning_dialog_title)) },
text = { Text(text = stringResource(R.string.switch_warning_dialog_description)) },
onDismissRequest = onDismiss,
confirmButton = {
WarningActionButton(onClick = onConfirm) {
Text(text = stringResource(R.string.confirm_switch))
}
},
dismissButton = {
DismissActionButton(onClick = onDismiss) { Text(text = stringResource(R.string.cancel)) }
})
}
Loading