Skip to content

Commit e0145bc

Browse files
achanuclaude
andcommitted
android: add Wi-Fi auto-connect feature
Adds per-SSID VPN control with three modes: - Whitelist (safe networks): VPN stops automatically on join, restarts on leave if unknown-network auto-start is enabled - Blacklist (unsafe networks): VPN starts automatically on join, stops on leave - Default-on: VPN starts on any network not in either list New screen under Settings → Wi-Fi auto-connect lets users manage both lists and toggle default-on. Location permission is requested to pre-fill the current SSID in the add-network dialog. Requires ACCESS_FINE_LOCATION for SSID detection on Android < 12. On API 29+ the SSID is read from NetworkCapabilities.transportInfo without a location permission prompt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8f28f4 commit e0145bc

10 files changed

Lines changed: 508 additions & 1 deletion

File tree

android/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
66
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
7+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
78
<uses-permission android:name="android.permission.INTERNET" />
89
<uses-permission
910
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
171171
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
172172
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
173173
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
174+
NetworkChangeCallback.monitorWifiAutoConnect(connectivityManager, this)
174175
initViewModels()
175176
applicationScope.launch {
176177
val restrictionsManager =
@@ -521,6 +522,9 @@ open class UninitializedApp : Application() {
521522
private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps"
522523

523524
private const val IS_CLIENT_LOGGING_ENABLED_KEY = "isClientLoggingEnabled"
525+
private const val WIFI_WHITELIST_SSIDS_KEY = "wifiAutoConnectSsids"
526+
private const val WIFI_BLACKLIST_SSIDS_KEY = "wifiBlacklistSsids"
527+
private const val WIFI_AUTO_CONNECT_DEFAULT_ON_KEY = "wifiAutoConnectDefaultOn"
524528
// File for shared preferences that are not encrypted.
525529
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
526530
private lateinit var appInstance: UninitializedApp
@@ -752,6 +756,32 @@ open class UninitializedApp : Application() {
752756
return appViewModel
753757
}
754758

759+
// Whitelist = safe networks where VPN is stopped automatically.
760+
fun getWhitelistSsids(): Set<String> {
761+
return getUnencryptedPrefs().getStringSet(WIFI_WHITELIST_SSIDS_KEY, emptySet()) ?: emptySet()
762+
}
763+
764+
fun setWhitelistSsids(ssids: Set<String>) {
765+
getUnencryptedPrefs().edit().putStringSet(WIFI_WHITELIST_SSIDS_KEY, ssids).apply()
766+
}
767+
768+
// Blacklist = unsafe networks where VPN is always started automatically.
769+
fun getBlacklistSsids(): Set<String> {
770+
return getUnencryptedPrefs().getStringSet(WIFI_BLACKLIST_SSIDS_KEY, emptySet()) ?: emptySet()
771+
}
772+
773+
fun setBlacklistSsids(ssids: Set<String>) {
774+
getUnencryptedPrefs().edit().putStringSet(WIFI_BLACKLIST_SSIDS_KEY, ssids).apply()
775+
}
776+
777+
fun getWifiAutoConnectDefaultOn(): Boolean {
778+
return getUnencryptedPrefs().getBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, false)
779+
}
780+
781+
fun setWifiAutoConnectDefaultOn(enabled: Boolean) {
782+
getUnencryptedPrefs().edit().putBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, enabled).apply()
783+
}
784+
755785
val builtInDisallowedPackageNames: List<String> =
756786
listOf(
757787
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322

android/src/main/java/com/tailscale/ipn/MainActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView
8888
import com.tailscale.ipn.ui.view.SearchView
8989
import com.tailscale.ipn.ui.view.SettingsView
9090
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
91+
import com.tailscale.ipn.ui.view.WifiAutoConnectView
9192
import com.tailscale.ipn.ui.view.SubnetRoutingView
9293
import com.tailscale.ipn.ui.view.TaildropDirView
9394
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
@@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
310311
onNavigateToManagedBy = { navController.navigate("managedBy") },
311312
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
312313
onNavigateToPermissions = { navController.navigate("permissions") },
314+
onNavigateToWifiAutoConnect = {
315+
navController.navigate("wifiAutoConnect")
316+
},
313317
onBackToSettings = backTo("settings"),
314318
onNavigateBackHome = backTo("main"))
315319
val exitNodePickerNav =
@@ -373,6 +377,7 @@ class MainActivity : ComponentActivity() {
373377
composable("bugReport") { BugReportView(backTo("settings")) }
374378
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
375379
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
380+
composable("wifiAutoConnect") { WifiAutoConnectView(backTo("settings")) }
376381
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
377382
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
378383
composable("about") { AboutView(backTo("settings")) }

android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
// SPDX-License-Identifier: BSD-3-Clause
33
package com.tailscale.ipn
44

5+
import android.content.Context
56
import android.net.ConnectivityManager
67
import android.net.LinkProperties
78
import android.net.Network
89
import android.net.NetworkCapabilities
910
import android.net.NetworkRequest
11+
import android.net.wifi.WifiInfo
12+
import android.net.wifi.WifiManager
13+
import android.os.Build
1014
import android.util.Log
15+
import androidx.work.ExistingWorkPolicy
16+
import androidx.work.OneTimeWorkRequest
17+
import androidx.work.WorkManager
1118
import com.tailscale.ipn.util.TSLog
1219
import java.util.concurrent.locks.ReentrantLock
1320
import kotlin.concurrent.withLock
@@ -37,6 +44,22 @@ object NetworkChangeCallback {
3744
var cachedDefaultInterfaceName: String? = null
3845
private set
3946

47+
// Networks where we auto-started VPN (blacklist or defaultOn). Tracked to auto-stop on leave.
48+
private val startedForNetworks = mutableMapOf<Network, String>()
49+
50+
// Networks where we auto-stopped VPN (whitelist). Tracked to restart VPN on leave if defaultOn.
51+
private val stoppedForNetworks = mutableMapOf<Network, String>()
52+
53+
// User manually stopped VPN on a blacklist/defaultOn network → suppress re-auto-start.
54+
private val userStoppedOnNetworks = mutableSetOf<Network>()
55+
56+
// User manually started VPN on a whitelist network → suppress auto-stop.
57+
private val userStartedOnWhitelist = mutableSetOf<Network>()
58+
59+
// Stored for checkExistingNetworks().
60+
private var wifiAutoConnectivity: ConnectivityManager? = null
61+
private var wifiAutoConnectApp: UninitializedApp? = null
62+
4063
// monitorDnsChanges sets up a network callback to monitor changes to the
4164
// system's network state and update the DNS configuration when interfaces
4265
// become available or properties of those interfaces change.
@@ -208,4 +231,154 @@ object NetworkChangeCallback {
208231
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
209232
}
210233
}
234+
235+
// userStoppedVpn: user manually stopped VPN → suppress re-auto-start on current networks.
236+
fun userStoppedVpn() {
237+
lock.withLock { userStoppedOnNetworks.addAll(startedForNetworks.keys) }
238+
}
239+
240+
// userStartedVpn: user manually started VPN → suppress auto-stop on whitelist networks.
241+
fun userStartedVpn() {
242+
lock.withLock { userStartedOnWhitelist.addAll(stoppedForNetworks.keys) }
243+
}
244+
245+
// monitorWifiAutoConnect registers a callback to auto-start the VPN on trusted SSIDs and
246+
// auto-stop it when leaving those networks.
247+
fun monitorWifiAutoConnect(connectivityManager: ConnectivityManager, app: UninitializedApp) {
248+
wifiAutoConnectivity = connectivityManager
249+
wifiAutoConnectApp = app
250+
val request =
251+
NetworkRequest.Builder()
252+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
253+
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
254+
.build()
255+
256+
connectivityManager.registerNetworkCallback(
257+
request,
258+
object : ConnectivityManager.NetworkCallback() {
259+
override fun onCapabilitiesChanged(
260+
network: Network,
261+
capabilities: NetworkCapabilities
262+
) {
263+
super.onCapabilitiesChanged(network, capabilities)
264+
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
265+
val ssid = getSsidFromCaps(capabilities, app) ?: return
266+
var action: (() -> Unit)? = null
267+
lock.withLock {
268+
val whitelist = app.getWhitelistSsids()
269+
val blacklist = app.getBlacklistSsids()
270+
val defaultOn = app.getWifiAutoConnectDefaultOn()
271+
val isWhitelisted = whitelist.contains(ssid)
272+
val isBlacklisted = blacklist.contains(ssid)
273+
TSLog.d(TAG, "wifi: ssid=$ssid whitelist=$whitelist blacklist=$blacklist defaultOn=$defaultOn")
274+
when {
275+
isWhitelisted -> {
276+
if (network in userStartedOnWhitelist) return@withLock
277+
if (network in stoppedForNetworks) return@withLock
278+
TSLog.d(TAG, "wifi: whitelist → stopping VPN")
279+
stoppedForNetworks[network] = ssid
280+
action = {
281+
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
282+
app.stopVPN()
283+
}
284+
}
285+
isBlacklisted || defaultOn -> {
286+
if (network in userStoppedOnNetworks) return@withLock
287+
if (network in startedForNetworks) return@withLock
288+
startedForNetworks[network] = ssid
289+
if (app.isAbleToStartVPN()) {
290+
TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → starting VPN")
291+
action = { enqueueVpnStart(app) }
292+
} else {
293+
TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → not yet authenticated, tracking network")
294+
}
295+
}
296+
else -> TSLog.d(TAG, "wifi: unknown network, defaultOn=false → no action")
297+
}
298+
}
299+
action?.invoke()
300+
}
301+
302+
override fun onLost(network: Network) {
303+
super.onLost(network)
304+
var action: (() -> Unit)? = null
305+
lock.withLock {
306+
val startedSsid = startedForNetworks.remove(network)
307+
val stoppedSsid = stoppedForNetworks.remove(network)
308+
userStoppedOnNetworks.remove(network)
309+
userStartedOnWhitelist.remove(network)
310+
if (startedSsid != null) {
311+
TSLog.d(TAG, "wifi: left blacklist/defaultOn network $startedSsid → stopping VPN")
312+
action = {
313+
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
314+
app.stopVPN()
315+
}
316+
} else if (stoppedSsid != null && app.getWifiAutoConnectDefaultOn()) {
317+
TSLog.d(TAG, "wifi: left whitelist network $stoppedSsid, defaultOn=true → starting VPN")
318+
action = { enqueueVpnStart(app) }
319+
}
320+
}
321+
action?.invoke()
322+
}
323+
})
324+
}
325+
326+
// checkExistingNetworks re-evaluates all active WiFi networks against current lists.
327+
// Call when whitelist, blacklist, or defaultOn setting changes.
328+
fun checkExistingNetworks() {
329+
wifiAutoConnectivity ?: return
330+
val app = wifiAutoConnectApp ?: return
331+
val actions = mutableListOf<() -> Unit>()
332+
lock.withLock {
333+
val whitelist = app.getWhitelistSsids()
334+
val blacklist = app.getBlacklistSsids()
335+
val defaultOn = app.getWifiAutoConnectDefaultOn()
336+
// Clean stale entries for SSIDs removed from their respective lists.
337+
if (!defaultOn) startedForNetworks.entries.removeIf { (_, ssid) -> !blacklist.contains(ssid) }
338+
stoppedForNetworks.entries.removeIf { (_, ssid) -> !whitelist.contains(ssid) }
339+
for ((network, info) in activeNetworks) {
340+
if (!info.caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
341+
val ssid = getSsidFromCaps(info.caps, app) ?: continue
342+
val isWhitelisted = whitelist.contains(ssid)
343+
val isBlacklisted = blacklist.contains(ssid)
344+
when {
345+
isWhitelisted -> {
346+
if (network in userStartedOnWhitelist) continue
347+
if (network in stoppedForNetworks) continue
348+
stoppedForNetworks[network] = ssid
349+
actions += {
350+
WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
351+
app.stopVPN()
352+
}
353+
}
354+
isBlacklisted || defaultOn -> {
355+
if (network in userStoppedOnNetworks) continue
356+
if (network in startedForNetworks) continue
357+
startedForNetworks[network] = ssid
358+
if (app.isAbleToStartVPN()) actions += { enqueueVpnStart(app) }
359+
}
360+
}
361+
}
362+
}
363+
actions.forEach { it() }
364+
}
365+
366+
private fun String?.cleanSsid(): String? =
367+
this?.removePrefix("\"")?.removeSuffix("\"")?.takeIf { it.isNotEmpty() && it != "<unknown ssid>" }
368+
369+
private fun getSsidFromCaps(capabilities: NetworkCapabilities, app: UninitializedApp): String? {
370+
// Try transportInfo first (preferred on API 29+, no location perm needed on API 31+).
371+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
372+
val fromTransport = (capabilities.transportInfo as? WifiInfo)?.ssid.cleanSsid()
373+
if (fromTransport != null) return fromTransport
374+
}
375+
// Fallback: WifiManager (works if ACCESS_FINE_LOCATION or NEARBY_WIFI_DEVICES is granted).
376+
@Suppress("DEPRECATION")
377+
return (app.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo?.ssid.cleanSsid()
378+
}
379+
380+
private fun enqueueVpnStart(app: UninitializedApp) {
381+
val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
382+
WorkManager.getInstance(app).enqueueUniqueWork("wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
383+
}
211384
}

android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ fun SettingsView(
9999
subtitle = stringResource(R.string.filter_apps_allowed_to_access_tailscale),
100100
onClick = settingsNav.onNavigateToSplitTunneling)
101101

102+
Lists.ItemDivider()
103+
Setting.Text(
104+
R.string.wifi_auto_connect,
105+
subtitle = stringResource(R.string.wifi_auto_connect_subtitle),
106+
onClick = settingsNav.onNavigateToWifiAutoConnect)
107+
102108
if (showTailnetLock.value == ShowHide.Show) {
103109
Lists.ItemDivider()
104110
Setting.Text(
@@ -278,5 +284,5 @@ fun SettingsPreview() {
278284
vm.tailNetLockEnabled.set(true)
279285
vm.isAdmin.set(true)
280286
vm.managedByOrganization.set("Tails and Scales Inc.")
281-
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
287+
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
282288
}

0 commit comments

Comments
 (0)