diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt index 5beb538a..468a20c3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt @@ -2,14 +2,18 @@ package be.mygod.vpnhotspot import android.annotation.TargetApi import android.os.Build +import androidx.core.os.BuildCompat import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing +import be.mygod.vpnhotspot.net.TetherType +import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber import java.net.NetworkInterface -abstract class RoutingManager(private val caller: Any, val downstream: String, private val isWifi: Boolean) { +abstract class RoutingManager(private val caller: Any, val downstream: String, + private val forceWifi: Boolean = false) : TetheringManager.TetheringEventCallback { companion object { private const val KEY_MASQUERADE_MODE = "service.masqueradeMode" var masqueradeMode: Routing.MasqueradeMode @@ -54,10 +58,12 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p val started get() = active[downstream] === this private var routing: Routing? = null + private var isWifi = forceWifi || TetherType.ofInterface(downstream).isWifi fun start() = when (val other = active.putIfAbsent(downstream, this)) { null -> { if (isWifi) WifiDoubleLock.acquire(this) + if (!forceWifi && BuildCompat.isAtLeastR()) TetheringManager.registerTetheringEventCallback(null, this) initRouting() } this -> true // already started @@ -66,6 +72,13 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p open fun ifaceHandler(iface: NetworkInterface) { } + override fun onTetherableInterfaceRegexpsChanged() { + val isWifiNow = TetherType.ofInterface(downstream).isWifi + if (isWifi == isWifiNow) return + if (isWifi) WifiDoubleLock.release(this) else WifiDoubleLock.acquire(this) + isWifi = isWifiNow + } + private fun initRouting() = try { routing = Routing(caller, downstream, this::ifaceHandler).apply { try { @@ -87,6 +100,7 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p fun stop() { if (active.remove(downstream, this)) { + if (!forceWifi && BuildCompat.isAtLeastR()) TetheringManager.unregisterTetheringEventCallback(this) if (isWifi) WifiDoubleLock.release(this) routing?.revert() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 5f5fcee5..08939003 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -4,14 +4,12 @@ import android.content.Intent import androidx.annotation.RequiresApi import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing -import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor import be.mygod.vpnhotspot.util.Event0 import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.coroutines.* import timber.log.Timber -import java.lang.IllegalStateException import java.util.concurrent.ConcurrentHashMap class TetheringService : IpNeighbourMonitoringService(), TetheringManager.TetheringEventCallback, CoroutineScope { @@ -31,7 +29,7 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether } private inner class Downstream(caller: Any, downstream: String, var monitor: Boolean = false) : - RoutingManager(caller, downstream, TetherType.ofInterface(downstream).isWifi) { + RoutingManager(caller, downstream) { override fun Routing.configure() { forward() masquerade(masqueradeMode) 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 f93cd16e..5bf38433 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt @@ -14,6 +14,7 @@ import android.view.ViewGroup import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu +import androidx.core.os.BuildCompat import androidx.databinding.BaseObservable import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -29,6 +30,7 @@ import be.mygod.vpnhotspot.Empty import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.databinding.FragmentClientsBinding import be.mygod.vpnhotspot.databinding.ListitemClientBinding +import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor import be.mygod.vpnhotspot.net.monitor.TrafficRecorder import be.mygod.vpnhotspot.room.AppDatabase @@ -45,7 +47,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.NumberFormat -class ClientsFragment : Fragment() { +class ClientsFragment : Fragment(), TetheringManager.TetheringEventCallback { @Parcelize data class NicknameArg(val mac: Long, val nickname: CharSequence) : Parcelable class NicknameDialogFragment : AlertDialogFragment() { @@ -167,8 +169,10 @@ class ClientsFragment : Fragment() { } private inner class ClientAdapter : ListAdapter(Client) { + var size = 0 + override fun submitList(list: MutableList?) { - super.submitList(list) + super.submitList(list) { size = list?.size ?: 0 } binding.swipeRefresher.isRefreshing = false } @@ -226,6 +230,7 @@ class ClientsFragment : Fragment() { } override fun onStart() { + if (BuildCompat.isAtLeastR()) TetheringManager.registerTetheringEventCallback(null, this) super.onStart() // we just put these two thing together as this is the only place we need to use this event for now TrafficRecorder.foregroundListeners[this] = adapter::updateTraffic @@ -239,5 +244,11 @@ class ClientsFragment : Fragment() { override fun onStop() { TrafficRecorder.foregroundListeners -= this super.onStop() + if (BuildCompat.isAtLeastR()) TetheringManager.unregisterTetheringEventCallback(this) + } + + override fun onTetherableInterfaceRegexpsChanged() { + // icon might be changed due to TetherType changes + adapter.notifyItemRangeChanged(0, adapter.size) } } 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 c0d207da..b8aa283a 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -32,12 +32,13 @@ import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.broadcastReceiver import be.mygod.vpnhotspot.util.isNotGone import be.mygod.vpnhotspot.widget.SmartSnackbar +import kotlinx.coroutines.CompletableDeferred import timber.log.Timber import java.lang.reflect.InvocationTargetException import java.net.NetworkInterface import java.net.SocketException -class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickListener { +class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickListener, TetheringManager.TetheringEventCallback { companion object { const val START_REPEATER = 4 const val START_LOCAL_ONLY_HOTSPOT = 1 @@ -64,15 +65,36 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick TetherManager.WifiLegacy(this@TetheringFragment) } + private var enabledIfaces = emptyList() + private var listDeferred = CompletableDeferred>().apply { complete(emptyList()) } + private fun updateEnabledTypes() { + this@TetheringFragment.enabledTypes = enabledIfaces.map { TetherType.ofInterface(it) }.toSet() + } + + suspend fun notifyInterfaceChanged(lastList: List? = null) { + @Suppress("NAME_SHADOWING") val lastList = lastList ?: listDeferred.await() + val first = lastList.indexOfFirst { it is InterfaceManager } + if (first >= 0) notifyItemRangeChanged(first, lastList.indexOfLast { it is InterfaceManager } - first + 1) + } + suspend fun notifyTetherTypeChanged() { + updateEnabledTypes() + val lastList = listDeferred.await() + notifyInterfaceChanged(lastList) + val first = lastList.indexOfLast { it !is TetherManager } + 1 + notifyItemRangeChanged(first, lastList.size - first) + } + fun update(activeIfaces: List, localOnlyIfaces: List, erroredIfaces: List) { + val deferred = CompletableDeferred>() + listDeferred = deferred ifaceLookup = try { NetworkInterface.getNetworkInterfaces().asSequence().associateBy { it.name } } catch (e: SocketException) { Timber.d(e) emptyMap() } - this@TetheringFragment.enabledTypes = - (activeIfaces + localOnlyIfaces).map { TetherType.ofInterface(it) }.toSet() + enabledIfaces = activeIfaces + localOnlyIfaces + updateEnabledTypes() val list = ArrayList() if (RepeaterService.supported) list.add(repeaterManager) @@ -94,7 +116,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick list.add(wifiManagerLegacy) wifiManagerLegacy.onTetheringStarted() } - submitList(list) + submitList(list) { deferred.complete(list) } } override fun getItemViewType(position: Int) = getItem(position).type @@ -230,18 +252,20 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick override fun onServiceConnected(name: ComponentName?, service: IBinder?) { binder = service as TetheringService.Binder service.routingsChanged[this] = { - requireContext().apply { - // flush tethered interfaces - unregisterReceiver(receiver) - registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) - } + lifecycleScope.launchWhenStarted { adapter.notifyInterfaceChanged() } } requireContext().registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) + if (BuildCompat.isAtLeastR()) TetheringManager.registerTetheringEventCallback(null, this) } override fun onServiceDisconnected(name: ComponentName?) { (binder ?: return).routingsChanged -= this binder = null + if (BuildCompat.isAtLeastR()) TetheringManager.unregisterTetheringEventCallback(this) requireContext().unregisterReceiver(receiver) } + + override fun onTetherableInterfaceRegexpsChanged() { + lifecycleScope.launchWhenStarted { adapter.notifyTetherTypeChanged() } + } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt index 723f1139..8f00cb56 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt @@ -10,6 +10,7 @@ import android.service.quicksettings.Tile import android.widget.Toast import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import androidx.core.os.BuildCompat import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.TetheringService import be.mygod.vpnhotspot.net.TetherType @@ -25,7 +26,8 @@ import java.io.IOException import java.lang.reflect.InvocationTargetException @RequiresApi(24) -sealed class TetheringTileService : KillableTileService(), TetheringManager.StartTetheringCallback { +sealed class TetheringTileService : KillableTileService(), TetheringManager.StartTetheringCallback, + TetheringManager.TetheringEventCallback { protected val tileOff by lazy { Icon.createWithResource(application, icon) } protected val tileOn by lazy { Icon.createWithResource(application, R.drawable.ic_quick_settings_tile_on) } @@ -47,12 +49,15 @@ sealed class TetheringTileService : KillableTileService(), TetheringManager.Star override fun onStartListening() { super.onStartListening() bindService(Intent(this, TetheringService::class.java), this, Context.BIND_AUTO_CREATE) + // we need to initialize tethered ASAP for onClick, which is not achievable using registerTetheringEventCallback tethered = registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) ?.tetheredIfaces + if (BuildCompat.isAtLeastR()) TetheringManager.registerTetheringEventCallback(null, this) updateTile() } override fun onStopListening() { + if (BuildCompat.isAtLeastR()) TetheringManager.unregisterTetheringEventCallback(this) unregisterReceiver(receiver) stopAndUnbind(this) super.onStopListening() @@ -60,7 +65,7 @@ sealed class TetheringTileService : KillableTileService(), TetheringManager.Star override fun onServiceConnected(name: ComponentName?, service: IBinder?) { binder = service as TetheringService.Binder - service.routingsChanged[this] = { updateTile() } + service.routingsChanged[this] = this::updateTile super.onServiceConnected(name, service) } @@ -127,6 +132,7 @@ sealed class TetheringTileService : KillableTileService(), TetheringManager.Star error?.let { Toast.makeText(this, TetheringManager.tetherErrorMessage(it), Toast.LENGTH_LONG).show() } updateTile() } + override fun onTetherableInterfaceRegexpsChanged() = updateTile() class Wifi : TetheringTileService() { override val labelString get() = R.string.tethering_manage_wifi diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt index f494e379..fa43bccd 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt @@ -1,6 +1,7 @@ package be.mygod.vpnhotspot.net import android.content.res.Resources +import androidx.annotation.RequiresApi import androidx.core.os.BuildCompat import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.R @@ -25,39 +26,50 @@ enum class TetherType { else -> false } - companion object { - private val usbRegexs: List - private val wifiRegexs: List - private val wifiP2pRegexs: List + companion object : TetheringManager.TetheringEventCallback { + private lateinit var usbRegexs: List + private lateinit var wifiRegexs: List + private var wifiP2pRegexs = emptyList() private val wimaxRegexs: List - private val bluetoothRegexs: List - private val ncmRegexs: List + private lateinit var bluetoothRegexs: List + private var ncmRegexs = emptyList() private val ethernetRegex: Pattern? + private var requiresUpdate = true private fun Pair.getRegexs(name: String) = second .getStringArray(second.getIdentifier(name, "array", first)) .filterNotNull() .map { it.toPattern() } + @RequiresApi(30) + private fun updateRegexs() { + requiresUpdate = false + val tethering = TetheringManager.PACKAGE to app.packageManager.getResourcesForApplication( + TetheringManager.resolvedService.serviceInfo.applicationInfo) + usbRegexs = tethering.getRegexs("config_tether_usb_regexs") + wifiRegexs = tethering.getRegexs("config_tether_wifi_regexs") + wifiP2pRegexs = tethering.getRegexs("config_tether_wifi_p2p_regexs") + bluetoothRegexs = tethering.getRegexs("config_tether_bluetooth_regexs") + ncmRegexs = tethering.getRegexs("config_tether_ncm_regexs") + } + + @RequiresApi(30) + override fun onTetherableInterfaceRegexpsChanged() { + requiresUpdate = true + } + /** * Source: https://android.googlesource.com/platform/frameworks/base/+/32e772f/packages/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java#93 */ init { val system = "android" to Resources.getSystem() if (BuildCompat.isAtLeastR()) { - val tethering = TetheringManager.PACKAGE to app.packageManager.getResourcesForApplication( - TetheringManager.resolvedService.serviceInfo.applicationInfo) - usbRegexs = tethering.getRegexs("config_tether_usb_regexs") - wifiRegexs = tethering.getRegexs("config_tether_wifi_regexs") - wifiP2pRegexs = tethering.getRegexs("config_tether_wifi_p2p_regexs") - bluetoothRegexs = tethering.getRegexs("config_tether_bluetooth_regexs") - ncmRegexs = tethering.getRegexs("config_tether_ncm_regexs") + TetheringManager.registerTetheringEventCallback(null, this) + updateRegexs() } else { usbRegexs = system.getRegexs("config_tether_usb_regexs") wifiRegexs = system.getRegexs("config_tether_wifi_regexs") - wifiP2pRegexs = emptyList() bluetoothRegexs = system.getRegexs("config_tether_bluetooth_regexs") - ncmRegexs = emptyList() } wimaxRegexs = system.getRegexs("config_tether_wimax_regexs") // available since Android 4.0: https://android.googlesource.com/platform/frameworks/base/+/c96a667162fab44a250503caccb770109a9cb69a @@ -66,11 +78,18 @@ enum class TetherType { } /** + * The result could change for the same interface since API 30+. + * It will be triggered by [TetheringManager.TetheringEventCallback.onTetherableInterfaceRegexpsChanged]. + * * Based on: https://android.googlesource.com/platform/frameworks/base/+/5d36f01/packages/Tethering/src/com/android/networkstack/tethering/Tethering.java#479 */ - fun ofInterface(iface: String?, p2pDev: String? = null) = when { + tailrec fun ofInterface(iface: String?, p2pDev: String? = null): TetherType = when { iface == null -> NONE iface == p2pDev -> WIFI_P2P + requiresUpdate -> { + if (BuildCompat.isAtLeastR()) updateRegexs() else error("unexpected requiresUpdate") + ofInterface(iface, p2pDev) + } wifiRegexs.any { it.matcher(iface).matches() } -> WIFI wifiP2pRegexs.any { it.matcher(iface).matches() } -> WIFI_P2P usbRegexs.any { it.matcher(iface).matches() } -> USB diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt index 81216cd8..96407a05 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt @@ -22,7 +22,9 @@ import be.mygod.vpnhotspot.util.ensureReceiverUnregistered import com.android.dx.stock.ProxyBuilder import timber.log.Timber import java.lang.ref.WeakReference +import java.lang.reflect.InvocationHandler import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method import java.lang.reflect.Proxy import java.util.concurrent.Executor @@ -359,6 +361,8 @@ object TetheringManager { * This will be called immediately after the callback is registered, and may be called * multiple times later upon changes. * + * CHANGED: This method will NOT be immediately called after registration. + * * *@param reg The new regular expressions. * @hide */ @@ -446,64 +450,61 @@ object TetheringManager { * @param callback the callback to be called when tethering has change events. */ @RequiresApi(30) - fun registerTetheringEventCallback(executor: Executor, callback: TetheringEventCallback) { + fun registerTetheringEventCallback(executor: Executor?, callback: TetheringEventCallback) { val reference = WeakReference(callback) val proxy = synchronized(callbackMap) { callbackMap.computeIfAbsent(callback) { Proxy.newProxyInstance(interfaceTetheringEventCallback.classLoader, - arrayOf(interfaceTetheringEventCallback)) { proxy, method, args -> - @Suppress("NAME_SHADOWING") val callback = reference.get() - when (val name = method.name) { - "onTetheringSupported" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - callback?.onTetheringSupported(args[0] as Boolean) - null - } - "onUpstreamChanged" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - callback?.onUpstreamChanged(args[0] as Network?) - null - } - "onTetherableInterfaceRegexpsChanged" -> { - callback?.onTetherableInterfaceRegexpsChanged() - null - } - "onTetherableInterfacesChanged" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - @Suppress("UNCHECKED_CAST") - callback?.onTetherableInterfacesChanged(args[0] as List) - null - } - "onTetheredInterfacesChanged" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - @Suppress("UNCHECKED_CAST") - callback?.onTetheredInterfacesChanged(args[0] as List) - null - } - "onError" -> { - if (args.size > 2) Timber.w("Unexpected args for $name: $args") - callback?.onError(args[0] as String, args[1] as Int) - null - } - "onClientsChanged" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - callback?.onClientsChanged(args[0] as Iterable<*>) - null - } - "onOffloadStatusChanged" -> { - if (args.size > 1) Timber.w("Unexpected args for $name: $args") - callback?.onOffloadStatusChanged(args[0] as Int) - null - } - else -> { - Timber.w("Unexpected method, calling super: $method") - ProxyBuilder.callSuper(proxy, method, args) + arrayOf(interfaceTetheringEventCallback), object : InvocationHandler { + private var regexpsSent = false + override fun invoke(proxy: Any, method: Method, args: Array): Any? { + @Suppress("NAME_SHADOWING") val callback = reference.get() + when (val name = method.name) { + "onTetheringSupported" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + callback?.onTetheringSupported(args[0] as Boolean) + } + "onUpstreamChanged" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + callback?.onUpstreamChanged(args[0] as Network?) + } + "onTetherableInterfaceRegexpsChanged" -> { + if (regexpsSent) callback?.onTetherableInterfaceRegexpsChanged() + regexpsSent = true + } + "onTetherableInterfacesChanged" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + @Suppress("UNCHECKED_CAST") + callback?.onTetherableInterfacesChanged(args[0] as List) + } + "onTetheredInterfacesChanged" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + @Suppress("UNCHECKED_CAST") + callback?.onTetheredInterfacesChanged(args[0] as List) + } + "onError" -> { + if (args.size > 2) Timber.w("Unexpected args for $name: $args") + callback?.onError(args[0] as String, args[1] as Int) + } + "onClientsChanged" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + callback?.onClientsChanged(args[0] as Iterable<*>) + } + "onOffloadStatusChanged" -> { + if (args.size > 1) Timber.w("Unexpected args for $name: $args") + callback?.onOffloadStatusChanged(args[0] as Int) + } + else -> { + Timber.w("Unexpected method, calling super: $method") + return ProxyBuilder.callSuper(proxy, method, args) + } } + return null } - } + }) } } - registerTetheringEventCallback.invoke(instance, executor, proxy) + registerTetheringEventCallback.invoke(instance, executor ?: null.makeExecutor(), proxy) } /** * Remove tethering event callback previously registered with