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