Skip to content
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
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
Expand Down
30 changes: 30 additions & 0 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
NetworkChangeCallback.monitorWifiAutoConnect(connectivityManager, this)
initViewModels()
applicationScope.launch {
val restrictionsManager =
Expand Down Expand Up @@ -521,6 +522,9 @@ open class UninitializedApp : Application() {
private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps"

private const val IS_CLIENT_LOGGING_ENABLED_KEY = "isClientLoggingEnabled"
private const val WIFI_WHITELIST_SSIDS_KEY = "wifiAutoConnectSsids"
private const val WIFI_BLACKLIST_SSIDS_KEY = "wifiBlacklistSsids"
private const val WIFI_AUTO_CONNECT_DEFAULT_ON_KEY = "wifiAutoConnectDefaultOn"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
Expand Down Expand Up @@ -752,6 +756,32 @@ open class UninitializedApp : Application() {
return appViewModel
}

// Whitelist = safe networks where VPN is stopped automatically.
fun getWhitelistSsids(): Set<String> {
return getUnencryptedPrefs().getStringSet(WIFI_WHITELIST_SSIDS_KEY, emptySet()) ?: emptySet()
}

fun setWhitelistSsids(ssids: Set<String>) {
getUnencryptedPrefs().edit().putStringSet(WIFI_WHITELIST_SSIDS_KEY, ssids).apply()
}

// Blacklist = unsafe networks where VPN is always started automatically.
fun getBlacklistSsids(): Set<String> {
return getUnencryptedPrefs().getStringSet(WIFI_BLACKLIST_SSIDS_KEY, emptySet()) ?: emptySet()
}

fun setBlacklistSsids(ssids: Set<String>) {
getUnencryptedPrefs().edit().putStringSet(WIFI_BLACKLIST_SSIDS_KEY, ssids).apply()
}

fun getWifiAutoConnectDefaultOn(): Boolean {
return getUnencryptedPrefs().getBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, false)
}

fun setWifiAutoConnectDefaultOn(enabled: Boolean) {
getUnencryptedPrefs().edit().putBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, enabled).apply()
}

val builtInDisallowedPackageNames: List<String> =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
Expand Down
5 changes: 5 additions & 0 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.WifiAutoConnectView
import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
Expand Down Expand Up @@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onNavigateToWifiAutoConnect = {
navController.navigate("wifiAutoConnect")
},
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
Expand Down Expand Up @@ -373,6 +377,7 @@ class MainActivity : ComponentActivity() {
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("wifiAutoConnect") { WifiAutoConnectView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
Expand Down
173 changes: 173 additions & 0 deletions android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn

import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.tailscale.ipn.util.TSLog
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
Expand Down Expand Up @@ -37,6 +44,22 @@ object NetworkChangeCallback {
var cachedDefaultInterfaceName: String? = null
private set

// Networks where we auto-started VPN (blacklist or defaultOn). Tracked to auto-stop on leave.
private val startedForNetworks = mutableMapOf<Network, String>()

// Networks where we auto-stopped VPN (whitelist). Tracked to restart VPN on leave if defaultOn.
private val stoppedForNetworks = mutableMapOf<Network, String>()

// User manually stopped VPN on a blacklist/defaultOn network → suppress re-auto-start.
private val userStoppedOnNetworks = mutableSetOf<Network>()

// User manually started VPN on a whitelist network → suppress auto-stop.
private val userStartedOnWhitelist = mutableSetOf<Network>()

// Stored for checkExistingNetworks().
private var wifiAutoConnectivity: ConnectivityManager? = null
private var wifiAutoConnectApp: UninitializedApp? = null

// monitorDnsChanges sets up a network callback to monitor changes to the
// system's network state and update the DNS configuration when interfaces
// become available or properties of those interfaces change.
Expand Down Expand Up @@ -208,4 +231,154 @@ object NetworkChangeCallback {
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
}
}

// userStoppedVpn: user manually stopped VPN → suppress re-auto-start on current networks.
fun userStoppedVpn() {
lock.withLock { userStoppedOnNetworks.addAll(startedForNetworks.keys) }
}

// userStartedVpn: user manually started VPN → suppress auto-stop on whitelist networks.
fun userStartedVpn() {
lock.withLock { userStartedOnWhitelist.addAll(stoppedForNetworks.keys) }
}

// monitorWifiAutoConnect registers a callback to auto-start the VPN on trusted SSIDs and
// auto-stop it when leaving those networks.
fun monitorWifiAutoConnect(connectivityManager: ConnectivityManager, app: UninitializedApp) {
wifiAutoConnectivity = connectivityManager
wifiAutoConnectApp = app
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()

connectivityManager.registerNetworkCallback(
request,
object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, capabilities)
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
val ssid = getSsidFromCaps(capabilities, app) ?: return
var action: (() -> Unit)? = null
lock.withLock {
val whitelist = app.getWhitelistSsids()
val blacklist = app.getBlacklistSsids()
val defaultOn = app.getWifiAutoConnectDefaultOn()
val isWhitelisted = whitelist.contains(ssid)
val isBlacklisted = blacklist.contains(ssid)
TSLog.d(TAG, "wifi: ssid=$ssid whitelist=$whitelist blacklist=$blacklist defaultOn=$defaultOn")
when {
isWhitelisted -> {
if (network in userStartedOnWhitelist) return@withLock
if (network in stoppedForNetworks) return@withLock
TSLog.d(TAG, "wifi: whitelist → stopping VPN")
stoppedForNetworks[network] = ssid
action = {
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
app.stopVPN()
}
}
isBlacklisted || defaultOn -> {
if (network in userStoppedOnNetworks) return@withLock
if (network in startedForNetworks) return@withLock
startedForNetworks[network] = ssid
if (app.isAbleToStartVPN()) {
TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → starting VPN")
action = { enqueueVpnStart(app) }
} else {
TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → not yet authenticated, tracking network")
}
}
else -> TSLog.d(TAG, "wifi: unknown network, defaultOn=false → no action")
}
}
action?.invoke()
}

override fun onLost(network: Network) {
super.onLost(network)
var action: (() -> Unit)? = null
lock.withLock {
val startedSsid = startedForNetworks.remove(network)
val stoppedSsid = stoppedForNetworks.remove(network)
userStoppedOnNetworks.remove(network)
userStartedOnWhitelist.remove(network)
if (startedSsid != null) {
TSLog.d(TAG, "wifi: left blacklist/defaultOn network $startedSsid → stopping VPN")
action = {
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
app.stopVPN()
}
} else if (stoppedSsid != null && app.getWifiAutoConnectDefaultOn()) {
TSLog.d(TAG, "wifi: left whitelist network $stoppedSsid, defaultOn=true → starting VPN")
action = { enqueueVpnStart(app) }
}
}
action?.invoke()
}
})
}

// checkExistingNetworks re-evaluates all active WiFi networks against current lists.
// Call when whitelist, blacklist, or defaultOn setting changes.
fun checkExistingNetworks() {
wifiAutoConnectivity ?: return
val app = wifiAutoConnectApp ?: return
val actions = mutableListOf<() -> Unit>()
lock.withLock {
val whitelist = app.getWhitelistSsids()
val blacklist = app.getBlacklistSsids()
val defaultOn = app.getWifiAutoConnectDefaultOn()
// Clean stale entries for SSIDs removed from their respective lists.
if (!defaultOn) startedForNetworks.entries.removeIf { (_, ssid) -> !blacklist.contains(ssid) }
stoppedForNetworks.entries.removeIf { (_, ssid) -> !whitelist.contains(ssid) }
for ((network, info) in activeNetworks) {
if (!info.caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
val ssid = getSsidFromCaps(info.caps, app) ?: continue
val isWhitelisted = whitelist.contains(ssid)
val isBlacklisted = blacklist.contains(ssid)
when {
isWhitelisted -> {
if (network in userStartedOnWhitelist) continue
if (network in stoppedForNetworks) continue
stoppedForNetworks[network] = ssid
actions += {
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
app.stopVPN()
}
}
isBlacklisted || defaultOn -> {
if (network in userStoppedOnNetworks) continue
if (network in startedForNetworks) continue
startedForNetworks[network] = ssid
if (app.isAbleToStartVPN()) actions += { enqueueVpnStart(app) }
}
}
}
}
actions.forEach { it() }
}

private fun String?.cleanSsid(): String? =
this?.removePrefix("\"")?.removeSuffix("\"")?.takeIf { it.isNotEmpty() && it != "<unknown ssid>" }

private fun getSsidFromCaps(capabilities: NetworkCapabilities, app: UninitializedApp): String? {
// Try transportInfo first (preferred on API 29+, no location perm needed on API 31+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val fromTransport = (capabilities.transportInfo as? WifiInfo)?.ssid.cleanSsid()
if (fromTransport != null) return fromTransport
}
// Fallback: WifiManager (works if ACCESS_FINE_LOCATION or NEARBY_WIFI_DEVICES is granted).
@Suppress("DEPRECATION")
return (app.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo?.ssid.cleanSsid()
}

private fun enqueueVpnStart(app: UninitializedApp) {
val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
WorkManager.getInstance(app).enqueueUniqueWork("wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ fun SettingsView(
subtitle = stringResource(R.string.filter_apps_allowed_to_access_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)

Lists.ItemDivider()
Setting.Text(
R.string.wifi_auto_connect,
subtitle = stringResource(R.string.wifi_auto_connect_subtitle),
onClick = settingsNav.onNavigateToWifiAutoConnect)

if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider()
Setting.Text(
Expand Down Expand Up @@ -278,5 +284,5 @@ fun SettingsPreview() {
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}
Loading