Skip to content

Commit

Permalink
android: implement app split tunneling support
Browse files Browse the repository at this point in the history
  • Loading branch information
agottardo committed Jun 26, 2024
1 parent 840a31d commit 020e7b0
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 15 deletions.
16 changes: 10 additions & 6 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
Expand All @@ -9,8 +10,11 @@
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

<!-- Disable input emulation on ChromeOS -->
<uses-feature
Expand All @@ -29,9 +33,9 @@
android:name=".App"
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.App.SplashScreen">
<activity
Expand Down Expand Up @@ -97,8 +101,8 @@
<service
android:name=".IPNService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="systemExempted">
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
Expand Down
46 changes: 44 additions & 2 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -382,12 +386,28 @@ 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")
}
}

fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
Expand Down Expand Up @@ -451,4 +471,26 @@ open class UninitializedApp : Application() {
.setContentIntent(pendingIntent)
.build()
}

fun addDisallowedPackageName(packageName: String) {
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
}

fun removeDisallowedPackageName(packageName: String) {
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
}

fun disallowedPackageNames(): List<String> {
return getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList()
?: emptyList()
}
}
23 changes: 19 additions & 4 deletions android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -124,6 +132,13 @@ open class IPNService : VpnService(), libtailscale.IPNService {

// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
disallowApp(b, "com.google.android.apps.chromecast.app")

// Any app that the user manually disallowed in the GUI
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
Log.d(TAG, "Disallowing user-set app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}

return VPNServiceBuilder(b)
}

Expand Down
3 changes: 3 additions & 0 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") },
Expand Down Expand Up @@ -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")) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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<InstalledApp> {
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
}
}
10 changes: 8 additions & 2 deletions android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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.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()

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("Available apps") }
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),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())

init {
excludedPackageNames.set(App.get().disallowedPackageNames())
installedApps.set(installedAppsManager.fetchInstalledApps())
}

fun exclude(packageName: String) {
if (excludedPackageNames.value.contains(packageName)) {
return
}
excludedPackageNames.set(excludedPackageNames.value + packageName)
App.get().addDisallowedPackageName(packageName)
}

fun unexclude(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName)
App.get().removeDisallowedPackageName(packageName)
}
}
Loading

0 comments on commit 020e7b0

Please sign in to comment.