android: implement app split tunneling support (#435)
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 <andrea@gottardo.me>jonathan/bump_oss
parent
a120eb2fe1
commit
9b24888c4c
@ -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<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
|
||||
}
|
||||
}
|
@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<List<String>> = MutableStateFlow(listOf())
|
||||
val installedApps: StateFlow<List<InstalledApp>> = 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue