From da9bf4867e5ebe79d10c476b4b019e5f35d6a10e Mon Sep 17 00:00:00 2001 From: Mygod Date: Sat, 2 Jun 2018 07:29:46 +0800 Subject: [PATCH] Support specifying network interface Fix #15. --- .../vpnhotspot/LocalOnlyHotspotService.kt | 4 +- .../vpnhotspot/LocalOnlyInterfaceManager.kt | 12 +- .../be/mygod/vpnhotspot/RepeaterService.kt | 3 +- .../be/mygod/vpnhotspot/TetheringService.kt | 39 +++--- .../vpnhotspot/client/ClientsFragment.kt | 8 +- .../vpnhotspot/manage/TetheringFragment.kt | 5 +- .../mygod/vpnhotspot/net/InterfaceMonitor.kt | 70 +++++++++++ .../java/be/mygod/vpnhotspot/net/IpMonitor.kt | 67 +++++++++++ .../vpnhotspot/net/IpNeighbourMonitor.kt | 95 ++++----------- .../mygod/vpnhotspot/net/UpstreamMonitor.kt | 68 +++++++++++ .../be/mygod/vpnhotspot/net/VpnMonitor.kt | 112 ++++++++---------- mobile/src/main/res/values-zh-rCN/strings.xml | 2 + mobile/src/main/res/values/strings.xml | 2 + mobile/src/main/res/xml/pref_settings.xml | 12 +- 14 files changed, 326 insertions(+), 173 deletions(-) create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/net/InterfaceMonitor.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/net/IpMonitor.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 44841d7b..644b1d0f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -37,8 +37,6 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { val iface = ifaces.singleOrNull() binder.iface = iface if (iface == null) { - routingManager?.stop() - routingManager = null unregisterReceiver() ServiceNotification.stopForeground(this) stopSelf() @@ -103,6 +101,8 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { } private fun unregisterReceiver() { + routingManager?.stop() + routingManager = null if (receiverRegistered) { unregisterReceiver(receiver) IpNeighbourMonitor.unregisterCallback(this) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt index e106e9ea..ae5a186d 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt @@ -3,28 +3,28 @@ package be.mygod.vpnhotspot import android.widget.Toast import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing -import be.mygod.vpnhotspot.net.VpnMonitor +import be.mygod.vpnhotspot.net.UpstreamMonitor import java.net.InetAddress import java.net.SocketException -class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetAddress? = null) : VpnMonitor.Callback { +class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetAddress? = null) : + UpstreamMonitor.Callback { private var routing: Routing? = null private var dns = emptyList() init { app.cleanRoutings[this] = this::clean - VpnMonitor.registerCallback(this) { initRouting() } + UpstreamMonitor.registerCallback(this) { initRouting() } } override fun onAvailable(ifname: String, dns: List) { val routing = routing initRouting(ifname, if (routing == null) owner else { routing.stop() - check(routing.upstream == null) routing.hostAddress }, dns) } - override fun onLost(ifname: String) { + override fun onLost() { val routing = routing ?: return if (!routing.stop()) app.toast(R.string.noisy_su_failure) initRouting(null, routing.hostAddress, emptyList()) @@ -54,7 +54,7 @@ class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetA } fun stop() { - VpnMonitor.unregisterCallback(this) + UpstreamMonitor.unregisterCallback(this) app.cleanRoutings -= this if (routing?.stop() == false) app.toast(R.string.noisy_su_failure) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 502abb0a..fda3425f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -177,7 +177,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere p2pManager.requestGroupInfo(channel, { when { it == null -> doStart() - it.isGroupOwner -> doStart(it) + it.isGroupOwner -> if (routingManager == null) doStart(it) else -> { Log.i(TAG, "Removing old group ($it)") p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener { @@ -220,6 +220,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere */ private fun doStart(group: WifiP2pGroup, ownerAddress: InetAddress? = null) { this.group = group + check(routingManager == null) routingManager = LocalOnlyInterfaceManager(group.`interface`!!, ownerAddress) status = Status.ACTIVE showNotification(group) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 61c96083..aeb36c70 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -8,12 +8,12 @@ import be.mygod.vpnhotspot.manage.TetheringFragment import be.mygod.vpnhotspot.net.IpNeighbourMonitor import be.mygod.vpnhotspot.net.Routing import be.mygod.vpnhotspot.net.TetheringManager -import be.mygod.vpnhotspot.net.VpnMonitor +import be.mygod.vpnhotspot.net.UpstreamMonitor import be.mygod.vpnhotspot.util.broadcastReceiver import java.net.InetAddress import java.net.SocketException -class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { +class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callback { companion object { const val EXTRA_ADD_INTERFACE = "interface.add" const val EXTRA_REMOVE_INTERFACE = "interface.remove" @@ -45,18 +45,20 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { val upstream = upstream if (upstream != null) { var failed = false - for ((downstream, value) in routings) if (value == null) try { - // system tethering already has working forwarding rules - // so it doesn't make sense to add additional forwarding rules - val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns) - if (app.pref.getBoolean("service.disableIpv6", false)) routing.disableIpv6() - routings[downstream] = routing - if (!routing.start()) failed = true - } catch (e: SocketException) { - e.printStackTrace() - routings.remove(downstream) - failed = true - } + for ((downstream, value) in routings) if (value == null || value.upstream != upstream) + try { + if (value?.stop() == false) failed = true + // system tethering already has working forwarding rules + // so it doesn't make sense to add additional forwarding rules + val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns) + if (app.pref.getBoolean("service.disableIpv6", false)) routing.disableIpv6() + routings[downstream] = routing + if (!routing.start()) failed = true + } catch (e: SocketException) { + e.printStackTrace() + routings.remove(downstream) + failed = true + } if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() } else if (!receiverRegistered) { registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) @@ -67,7 +69,7 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { } } IpNeighbourMonitor.registerCallback(this) - VpnMonitor.registerCallback(this) + UpstreamMonitor.registerCallback(this) receiverRegistered = true } updateNotification() @@ -94,14 +96,13 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { } override fun onAvailable(ifname: String, dns: List) { - check(upstream == null || upstream == ifname) + if (upstream == ifname) return upstream = ifname this.dns = dns synchronized(routings) { updateRoutingsLocked() } } - override fun onLost(ifname: String) { - check(upstream == null || upstream == ifname) + override fun onLost() { upstream = null this.dns = emptyList() var failed = false @@ -124,7 +125,7 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { unregisterReceiver(receiver) app.cleanRoutings -= this IpNeighbourMonitor.unregisterCallback(this) - VpnMonitor.unregisterCallback(this) + UpstreamMonitor.unregisterCallback(this) upstream = null receiverRegistered = false } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt index 6e13b7cf..27b1c6ce 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt @@ -59,10 +59,8 @@ class ClientsFragment : Fragment(), ServiceConnection { } override fun onServiceDisconnected(name: ComponentName?) { - val clients = clients - if (clients != null) { - clients.clientsChanged -= this - this.clients = null - } + val clients = clients ?: return + clients.clientsChanged -= this + this.clients = null } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt index f1e9dc30..34124a16 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -115,9 +115,8 @@ class TetheringFragment : Fragment(), ServiceConnection { } override fun onServiceDisconnected(name: ComponentName?) { - val context = requireContext() - tetheringBinder?.fragment = null + (tetheringBinder ?: return).fragment = null tetheringBinder = null - context.unregisterReceiver(receiver) + requireContext().unregisterReceiver(receiver) } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/InterfaceMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/InterfaceMonitor.kt new file mode 100644 index 00000000..aff81190 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/InterfaceMonitor.kt @@ -0,0 +1,70 @@ +package be.mygod.vpnhotspot.net + +import be.mygod.vpnhotspot.App.Companion.app + +class InterfaceMonitor(val iface: String) : UpstreamMonitor() { + companion object { + /** + * Based on: https://android.googlesource.com/platform/external/iproute2/+/70556c1/ip/ipaddress.c#1053 + */ + private val parser = ("^(Deleted )?-?\\d+: ([^:@]+)").toRegex() + } + + private inner class IpLinkMonitor : IpMonitor() { + override val monitoredObject: String get() = "link" + + override fun processLine(line: String) { + val match = parser.find(line) ?: return + if (match.groupValues[2] != iface) return + setPresent(match.groupValues[1].isEmpty()) + } + + override fun processLines(lines: Sequence) = + setPresent(lines.any { parser.find(it)?.groupValues?.get(2) == iface }) + } + + private fun setPresent(present: Boolean) = if (initializing) { + initializedPresent = present + currentIface = if (present) iface else null + } else synchronized(this) { + val old = currentIface != null + if (present == old) return + currentIface = if (present) iface else null + if (present) { + val dns = dns + callbacks.forEach { it.onAvailable(iface, dns) } + } else callbacks.forEach { it.onLost() } + } + + private var monitor: IpLinkMonitor? = null + private var initializing = false + private var initializedPresent: Boolean? = null + override var currentIface: String? = null + private set + private val dns get() = app.connectivity.allNetworks + .map { app.connectivity.getLinkProperties(it) } + .singleOrNull { it.interfaceName == iface } + ?.dnsServers ?: emptyList() + + override fun registerCallbackLocked(callback: Callback): Boolean { + var monitor = monitor + val present = if (monitor == null) { + initializing = true + initializedPresent = null + monitor = IpLinkMonitor() + this.monitor = monitor + monitor.run() + initializing = false + initializedPresent!! + } else currentIface != null + if (present) callback.onAvailable(iface, dns) + return !present + } + + override fun destroyLocked() { + val monitor = monitor ?: return + this.monitor = null + currentIface = null + monitor.destroy() + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpMonitor.kt new file mode 100644 index 00000000..23aaec7f --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpMonitor.kt @@ -0,0 +1,67 @@ +package be.mygod.vpnhotspot.net + +import android.util.Log +import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.R +import be.mygod.vpnhotspot.util.thread +import java.io.InterruptedIOException +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +abstract class IpMonitor : Runnable { + protected abstract val monitoredObject: String + protected abstract fun processLine(line: String) + protected abstract fun processLines(lines: Sequence) + + private var monitor: Process? = null + private var pool: ScheduledExecutorService? = null + + init { + thread("${javaClass.simpleName}-input") { + // monitor may get rejected by SELinux + val monitor = ProcessBuilder("sh", "-c", + "ip monitor $monitoredObject || su -c 'ip monitor $monitoredObject'") + .redirectErrorStream(true) + .start() + this.monitor = monitor + thread("${javaClass.simpleName}-error") { + try { + monitor.errorStream.bufferedReader().forEachLine { Log.e(javaClass.simpleName, it) } + } catch (_: InterruptedIOException) { } + } + try { + monitor.inputStream.bufferedReader().forEachLine(this::processLine) + monitor.waitFor() + if (monitor.exitValue() == 0) return@thread + Log.w(javaClass.simpleName, "Failed to set up monitor, switching to polling") + val pool = Executors.newScheduledThreadPool(1) + pool.scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS) + this.pool = pool + } catch (_: InterruptedIOException) { } + } + } + + fun flush() = thread("${javaClass.simpleName}-flush") { run() } + + override fun run() { + val process = ProcessBuilder("ip", monitoredObject) + .redirectErrorStream(true) + .start() + process.waitFor() + thread("${javaClass.simpleName}-flush-error") { + val err = process.errorStream.bufferedReader().readText() + if (err.isNotBlank()) { + Log.e(javaClass.simpleName, err) + app.toast(R.string.noisy_su_failure) + } + } + process.inputStream.bufferedReader().useLines(this::processLines) + } + + fun destroy() { + val monitor = monitor + if (monitor != null) thread("${javaClass.simpleName}-killer") { monitor.destroy() } + pool?.shutdown() + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt index e1a0357f..21468c0b 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt @@ -1,19 +1,10 @@ package be.mygod.vpnhotspot.net -import android.os.Handler -import android.util.Log import be.mygod.vpnhotspot.App.Companion.app -import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.util.debugLog -import be.mygod.vpnhotspot.util.thread -import java.io.InterruptedIOException -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit -class IpNeighbourMonitor private constructor() : Runnable { +class IpNeighbourMonitor private constructor() : IpMonitor() { companion object { - private const val TAG = "IpNeighbourMonitor" private val callbacks = HashSet() var instance: IpNeighbourMonitor? = null @@ -39,75 +30,37 @@ class IpNeighbourMonitor private constructor() : Runnable { fun onIpNeighbourAvailable(neighbours: List) } - private val handler = Handler() private var updatePosted = false val neighbours = HashMap() - private var monitor: Process? = null - private var pool: ScheduledExecutorService? = null - init { - thread("$TAG-input") { - // monitor may get rejected by SELinux - val monitor = ProcessBuilder("sh", "-c", "ip monitor neigh || su -c 'ip monitor neigh'") - .redirectErrorStream(true) - .start() - this.monitor = monitor - thread("$TAG-error") { - try { - monitor.errorStream.bufferedReader().forEachLine { Log.e(TAG, it) } - } catch (_: InterruptedIOException) { } - } - try { - monitor.inputStream.bufferedReader().forEachLine { - synchronized(neighbours) { - val neighbour = IpNeighbour.parse(it) ?: return@forEachLine - debugLog(TAG, it) - val changed = if (neighbour.state == IpNeighbour.State.DELETING) - neighbours.remove(neighbour.ip) != null - else neighbours.put(neighbour.ip, neighbour) != neighbour - if (changed) postUpdateLocked() - } - } - monitor.waitFor() - if (monitor.exitValue() == 0) return@thread - Log.w(TAG, "Failed to set up monitor, switching to polling") - val pool = Executors.newScheduledThreadPool(1) - pool.scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS) - this.pool = pool - } catch (_: InterruptedIOException) { } + override val monitoredObject: String get() = "neigh" + + override fun processLine(line: String) { + synchronized(neighbours) { + val neighbour = IpNeighbour.parse(line) ?: return + debugLog(javaClass.simpleName, line) + val changed = if (neighbour.state == IpNeighbour.State.DELETING) + neighbours.remove(neighbour.ip) != null + else neighbours.put(neighbour.ip, neighbour) != neighbour + if (changed) postUpdateLocked() } } - fun flush() = thread("$TAG-flush") { run() } - - override fun run() { - val process = ProcessBuilder("ip", "neigh") - .redirectErrorStream(true) - .start() - process.waitFor() - thread("$TAG-flush-error") { - val err = process.errorStream.bufferedReader().readText() - if (err.isNotBlank()) { - Log.e(TAG, err) - app.toast(R.string.noisy_su_failure) - } - } - process.inputStream.bufferedReader().useLines { - synchronized(neighbours) { - neighbours.clear() - neighbours.putAll(it - .map(IpNeighbour.Companion::parse) - .filterNotNull() - .filter { it.state != IpNeighbour.State.DELETING } // skip entries without lladdr - .associateBy { it.ip }) - postUpdateLocked() - } + override fun processLines(lines: Sequence) { + synchronized(neighbours) { + neighbours.clear() + neighbours.putAll(lines + .map(IpNeighbour.Companion::parse) + .filterNotNull() + .filter { it.state != IpNeighbour.State.DELETING } // skip entries without lladdr + .associateBy { it.ip }) + postUpdateLocked() } } private fun postUpdateLocked() { if (updatePosted || instance != this) return - handler.post { + app.handler.post { val neighbours = synchronized(neighbours) { updatePosted = false neighbours.values.toList() @@ -116,10 +69,4 @@ class IpNeighbourMonitor private constructor() : Runnable { } updatePosted = true } - - fun destroy() { - val monitor = monitor - if (monitor != null) thread("$TAG-killer") { monitor.destroy() } - pool?.shutdown() - } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt new file mode 100644 index 00000000..58319afd --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt @@ -0,0 +1,68 @@ +package be.mygod.vpnhotspot.net + +import android.content.SharedPreferences +import be.mygod.vpnhotspot.App.Companion.app +import java.net.InetAddress + +abstract class UpstreamMonitor { + companion object : SharedPreferences.OnSharedPreferenceChangeListener { + private const val KEY = "service.upstream" + + init { + app.pref.registerOnSharedPreferenceChangeListener(this) + } + + private fun generateMonitor(): UpstreamMonitor { + val upstream = app.pref.getString(KEY, null) + return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream) + } + private var monitor = generateMonitor() + + fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) = synchronized(this) { + monitor.registerCallback(callback, failfast) + } + fun unregisterCallback(callback: Callback) = synchronized(this) { monitor.unregisterCallback(callback) } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == KEY) synchronized(this) { + val old = monitor + val (active, callbacks) = synchronized(old) { + val active = old.currentIface != null + val callbacks = old.callbacks.toList() + old.callbacks.clear() + old.destroyLocked() + Pair(active, callbacks) + } + val new = generateMonitor() + monitor = new + callbacks.forEach { new.registerCallback(it) { if (active) it.onLost() } } + } + } + } + + interface Callback { + /** + * Called if some interface is available. This might be called on different ifname without having called onLost. + */ + fun onAvailable(ifname: String, dns: List) + /** + * Called if no interface is available. + */ + fun onLost() + } + + protected val callbacks = HashSet() + abstract val currentIface: String? + protected abstract fun registerCallbackLocked(callback: Callback): Boolean + protected abstract fun destroyLocked() + + fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) { + if (synchronized(this) { + if (!callbacks.add(callback)) return + registerCallbackLocked(callback) + }) failfast?.invoke() + } + fun unregisterCallback(callback: Callback) = synchronized(this) { + if (callbacks.remove(callback) && callbacks.isEmpty()) destroyLocked() + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/VpnMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/VpnMonitor.kt index 9030c343..bc282b3e 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/VpnMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/VpnMonitor.kt @@ -6,21 +6,14 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.util.debugLog -import java.net.InetAddress - -object VpnMonitor : ConnectivityManager.NetworkCallback() { - interface Callback { - fun onAvailable(ifname: String, dns: List) - fun onLost(ifname: String) - } +object VpnMonitor : UpstreamMonitor() { private const val TAG = "VpnMonitor" private val request = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_VPN) .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .build() - private val callbacks = HashSet() private var registered = false /** @@ -28,65 +21,64 @@ object VpnMonitor : ConnectivityManager.NetworkCallback() { */ private val available = HashMap() private var currentNetwork: Network? = null - override fun onAvailable(network: Network) { - val properties = app.connectivity.getLinkProperties(network) - val ifname = properties?.interfaceName ?: return - synchronized(this) { - if (available.put(network, ifname) != null) return - debugLog(TAG, "onAvailable: $ifname, ${properties.dnsServers.joinToString()}") - val old = currentNetwork - if (old != null) { - val name = available[old]!! - debugLog(TAG, "Assuming old VPN interface $name is dying") - callbacks.forEach { it.onLost(name) } + override val currentIface: String? get() { + val currentNetwork = currentNetwork + return if (currentNetwork == null) null else available[currentNetwork] + } + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + val properties = app.connectivity.getLinkProperties(network) + val ifname = properties?.interfaceName ?: return + synchronized(this@VpnMonitor) { + if (available.put(network, ifname) != null) return + debugLog(TAG, "onAvailable: $ifname, ${properties.dnsServers.joinToString()}") + val old = currentNetwork + if (old != null) debugLog(TAG, "Assuming old VPN interface ${available[old]} is dying") + currentNetwork = network + callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } } - currentNetwork = network - callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } + } + + override fun onLost(network: Network) = synchronized(this@VpnMonitor) { + val ifname = available.remove(network) ?: return + debugLog(TAG, "onLost: $ifname") + if (currentNetwork != network) return + while (available.isNotEmpty()) { + val next = available.entries.first() + currentNetwork = next.key + val properties = app.connectivity.getLinkProperties(next.key) + if (properties != null) { + debugLog(TAG, "Switching to ${next.value} as VPN interface") + callbacks.forEach { it.onAvailable(next.value, properties.dnsServers) } + return + } + available.remove(next.key) + } + callbacks.forEach { it.onLost() } + currentNetwork = null } } - override fun onLost(network: Network) = synchronized(this) { - val ifname = available.remove(network) ?: return - debugLog(TAG, "onLost: $ifname") - if (currentNetwork != network) return - callbacks.forEach { it.onLost(ifname) } - while (available.isNotEmpty()) { - val next = available.entries.first() - currentNetwork = next.key - val properties = app.connectivity.getLinkProperties(next.key) - if (properties != null) { - debugLog(TAG, "Switching to ${next.value} as VPN interface") - callbacks.forEach { it.onAvailable(next.value, properties.dnsServers) } - return - } - available.remove(next.key) + override fun registerCallbackLocked(callback: Callback) = if (registered) { + val currentNetwork = currentNetwork + if (currentNetwork == null) true else { + callback.onAvailable(available[currentNetwork]!!, + app.connectivity.getLinkProperties(currentNetwork)?.dnsServers ?: emptyList()) + false + } + } else { + app.connectivity.registerNetworkCallback(request, networkCallback) + registered = true + app.connectivity.allNetworks.all { + val cap = app.connectivity.getNetworkCapabilities(it) + cap == null || !cap.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } - currentNetwork = null } - fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) { - if (synchronized(this) { - if (!callbacks.add(callback)) return - if (!registered) { - app.connectivity.registerNetworkCallback(request, this) - registered = true - app.connectivity.allNetworks.all { - val cap = app.connectivity.getNetworkCapabilities(it) - cap == null || !cap.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } - } else if (available.isEmpty()) true else { - available.forEach { - callback.onAvailable(it.value, - app.connectivity.getLinkProperties(it.key)?.dnsServers ?: emptyList()) - } - false - } - }) failfast?.invoke() - } - fun unregisterCallback(callback: Callback) = synchronized(this) { - if (!callbacks.remove(callback) || callbacks.isNotEmpty() || !registered) return - app.connectivity.unregisterNetworkCallback(this) + override fun destroyLocked() { + if (!registered) return + app.connectivity.unregisterNetworkCallback(networkCallback) registered = false available.clear() currentNetwork = null diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index a74ead10..910f2d7c 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -63,6 +63,8 @@ 禁用 IPv6 共享 防止 IPv6 VPN 泄漏。 备用 DNS 服务器[:端口] + 上游网络接口 + 自动检测系统 VPN 清理/重新应用路由规则 杂项 导出调试信息 diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 507e27cf..bd38d224 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -67,6 +67,8 @@ Disable IPv6 tethering Enabling this option will prevent VPN leaks via IPv6. Fallback DNS server[:port] + Upstream network interface + Auto detect system VPN Clean/reapply routing rules Misc Export debug information diff --git a/mobile/src/main/res/xml/pref_settings.xml b/mobile/src/main/res/xml/pref_settings.xml index 9f8dd9b0..29460022 100644 --- a/mobile/src/main/res/xml/pref_settings.xml +++ b/mobile/src/main/res/xml/pref_settings.xml @@ -2,6 +2,9 @@ + - +