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
7 changes: 7 additions & 0 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.tailscale.ipn.autoconnect.NetworkWatcher
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
import com.tailscale.ipn.ui.localapi.Client
Expand Down Expand Up @@ -88,6 +89,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {

private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
lateinit var networkWatcher: NetworkWatcher
private set

override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString

Expand Down Expand Up @@ -128,10 +131,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)

networkWatcher = NetworkWatcher(this)
networkWatcher.register()
}

override fun onTerminate() {
super.onTerminate()
networkWatcher.unregister()
Notifier.stop()
notificationManager.cancelAll()
applicationScope.cancel()
Expand Down
7 changes: 7 additions & 0 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.TrustedNetworksView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.AppViewModel
Expand Down Expand Up @@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onNavigateToTrustedNetworks = {
navController.navigate("trustedNetworks")
},
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
Expand Down Expand Up @@ -375,6 +379,9 @@ class MainActivity : ComponentActivity() {
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
composable("trustedNetworks") {
TrustedNetworksView(onNavigateBack = backTo("settings"))
}
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
Expand Down
201 changes: 201 additions & 0 deletions android/src/main/java/com/tailscale/ipn/autoconnect/NetworkWatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.autoconnect

import android.content.Context
import android.net.ConnectivityManager
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.Handler
import android.os.Looper
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog

class NetworkWatcher(private val context: Context) {

private val cm =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

private var registered = false
private val handler = Handler(Looper.getMainLooper())
private var pendingEvaluation: Runnable? = null
private var lastAction: String? = null
private var ssidRetryCount = 0

fun register() {
if (registered) return
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
cm.registerNetworkCallback(request, callback)
registered = true
evaluateCurrent()
}

fun unregister() {
if (!registered) return
cm.unregisterNetworkCallback(callback)
registered = false
}

private fun scheduleEvaluation() {
pendingEvaluation?.let { handler.removeCallbacks(it) }
val runnable = Runnable { evaluateCurrent() }
pendingEvaluation = runnable
handler.postDelayed(runnable, DEBOUNCE_MS)
}

private val callback =
object : ConnectivityManager.NetworkCallback() {

override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities
) {
if (!TrustedNetworks.isEnabled(context)) return
// Ignore VPN network changes — they are created by us
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
scheduleEvaluation()
}

override fun onLost(network: Network) {
if (!TrustedNetworks.isEnabled(context)) return
scheduleEvaluation()
}
}

private fun getSsid(caps: NetworkCapabilities): String? {
// Method 1: transportInfo from NetworkCapabilities
var ssid =
(caps.transportInfo as? WifiInfo)?.ssid?.trim('"')
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
if (ssid != null) {
TSLog.d(TAG, "SSID from transportInfo: $ssid")
return ssid
}

// Method 2: WifiManager.connectionInfo (deprecated but works on some Android 12+ devices)
val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
ssid = wm.connectionInfo?.ssid?.trim('"')
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
if (ssid != null) {
TSLog.d(TAG, "SSID from WifiManager: $ssid")
return ssid
}

// Method 3: WifiManager.scanResults — match by BSSID from connectionInfo
@Suppress("DEPRECATION")
val bssid = wm.connectionInfo?.bssid
if (bssid != null) {
ssid = wm.scanResults
?.firstOrNull { it.BSSID == bssid }
?.SSID
?.trim('"')
?.takeIf { it.isNotBlank() }
if (ssid != null) {
TSLog.d(TAG, "SSID from scanResults: $ssid")
return ssid
}
}

TSLog.d(TAG, "Could not determine SSID")
return null
}

private fun handleWifi(caps: NetworkCapabilities) {
val ssid = getSsid(caps)

if (ssid == null) {
// SSID may not be available yet after Wi-Fi association — retry a few times
if (ssidRetryCount < MAX_SSID_RETRIES) {
ssidRetryCount++
TSLog.d(TAG, "SSID null on Wi-Fi, scheduling retry $ssidRetryCount/$MAX_SSID_RETRIES")
handler.postDelayed({ evaluateCurrent() }, SSID_RETRY_DELAY_MS)
}
return
}

ssidRetryCount = 0
val trusted = TrustedNetworks.load(context)
TSLog.d(TAG, "SSID='$ssid' trusted_list=$trusted match=${ssid in trusted}")
if (ssid in trusted) {
disableVpn()
} else {
enableVpn()
}
}

private fun enableVpn() {
if (lastAction == "enable") return
val state = Notifier.state.value
if (state == Ipn.State.Running) {
lastAction = "enable"
return
}
lastAction = "enable"
TSLog.d(TAG, "Enabling VPN (auto-vpn)")
App.get().startVPN()
}

private fun disableVpn() {
if (lastAction == "disable") return
val state = Notifier.state.value
if (state != Ipn.State.Running) {
lastAction = "disable"
return
}
lastAction = "disable"
TSLog.d(TAG, "Disabling VPN (trusted network)")
App.get().stopVPN()
}

fun reevaluate() {
lastAction = null
ssidRetryCount = 0
evaluateCurrent()
}

fun evaluateCurrent() {
if (!TrustedNetworks.isEnabled(context)) return

// Find the underlying Wi-Fi or cellular network, skipping VPN
var wifiCaps: NetworkCapabilities? = null
var hasCellular = false

for (network in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(network) ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
wifiCaps = caps
break // Wi-Fi takes priority
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
hasCellular = true
}
}

TSLog.d(TAG, "evaluateCurrent: wifi=${wifiCaps != null} cellular=$hasCellular")

when {
wifiCaps != null -> handleWifi(wifiCaps)
hasCellular -> enableVpn()
else -> enableVpn()
}
}

companion object {
private const val TAG = "NetworkWatcher"
private const val DEBOUNCE_MS = 2000L
private const val SSID_RETRY_DELAY_MS = 3000L
private const val MAX_SSID_RETRIES = 5
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.autoconnect

import android.content.Context

object TrustedNetworks {
private const val PREFS_NAME = "tailscale_auto"
private const val KEY_SSIDS = "trusted_ssids"
private const val KEY_ENABLED = "auto_vpn_enabled"

fun isEnabled(ctx: Context): Boolean =
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean(KEY_ENABLED, false)

fun setEnabled(ctx: Context, enabled: Boolean) =
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_ENABLED, enabled)
.apply()

fun load(ctx: Context): Set<String> =
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getStringSet(KEY_SSIDS, emptySet()) ?: emptySet()

fun save(ctx: Context, ssids: Set<String>) =
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putStringSet(KEY_SSIDS, ssids)
.apply()

fun add(ctx: Context, ssid: String) = save(ctx, load(ctx) + ssid)

fun remove(ctx: Context, ssid: String) = save(ctx, load(ctx) - ssid)
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ object Permissions {
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed))
}
result.add(
Permission(
Manifest.permission.ACCESS_FINE_LOCATION,
R.string.permission_location,
R.string.permission_location_needed))
result
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ fun SettingsView(
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}

Lists.ItemDivider()
Setting.Text(
R.string.auto_vpn,
subtitle = "Auto-toggle VPN based on network",
onClick = settingsNav.onNavigateToTrustedNetworks)

managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
Expand Down Expand Up @@ -219,5 +225,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