diff --git a/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt b/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt index 31646a0d..ae465a41 100644 --- a/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt +++ b/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.channels.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.* -import java.lang.ref.WeakReference import java.util.* import java.util.concurrent.CountDownLatch import kotlin.system.exitProcess @@ -191,6 +190,9 @@ class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> U } try { callbackSpin() + } catch (e: Throwable) { + process.destroy() + throw e } finally { if (DEBUG) Log.d(TAG, "Waiting for exit") process.waitFor() @@ -270,9 +272,13 @@ class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> U if (active) { active = false if (DEBUG) Log.d(TAG, "Shutting down from client") - sendLocked(Shutdown()) - output.close() - process.outputStream.close() + try { + sendLocked(Shutdown()) + output.close() + process.outputStream.close() + } catch (e: IOException) { + Log.i(TAG, "send Shutdown failed", e) + } if (DEBUG) Log.d(TAG, "Client closed") } if (fromWorker) { @@ -375,7 +381,7 @@ class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> U CoroutineScope(Dispatchers.Main.immediate + job) } val callbackWorker = newSingleThreadContext("callbackWorker") - val channels = LongSparseArray>>() + val channels = LongSparseArray>() // thread safety: usage of output should be guarded by callbackWorker val output = DataOutputStream(System.out.buffered().apply { @@ -396,7 +402,7 @@ class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> U val callback = counter if (DEBUG) Log.d(TAG, "Received #$callback: $command") when (command) { - is ChannelClosed -> channels[command.index]?.get()?.cancel() + is ChannelClosed -> channels[command.index]?.cancel() is RootCommandOneWay -> defaultWorker.launch { try { command.execute() @@ -415,10 +421,10 @@ class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> U } is RootCommandChannel<*> -> defaultWorker.launch { val result = try { - command.create(defaultWorker).also { - channels[callback] = WeakReference(it) - }.consumeEach { result -> - withContext(callbackWorker) { output.pushResult(callback, result) } + coroutineScope { + command.create(this).also { channels[callback] = it }.consumeEach { result -> + withContext(callbackWorker) { output.pushResult(callback, result) } + } }; @Suppress("BlockingMethodInNonBlockingContext") { output.writeByte(CHANNEL_CONSUMED) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt index 3e2f414d..68afcf95 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt @@ -66,7 +66,10 @@ class App : Application() { if (priority != Log.DEBUG || BuildConfig.DEBUG) Log.println(priority, tag, message) FirebaseCrashlytics.getInstance().log("${"XXVDIWEF".getOrElse(priority) { 'X' }}/$tag: $message") } else { - if (priority >= Log.WARN || priority == Log.DEBUG) Log.println(priority, tag, message) + if (priority >= Log.WARN || priority == Log.DEBUG) { + Log.println(priority, tag, message) + Log.d(tag, message, t) + } if (priority >= Log.INFO && t !is NoShellException) { FirebaseCrashlytics.getInstance().recordException(t) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index 5e13c477..d416ed4d 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import androidx.core.content.FileProvider +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference @@ -24,6 +25,7 @@ import be.mygod.vpnhotspot.util.showAllowingStateLoss import be.mygod.vpnhotspot.widget.SmartSnackbar import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -48,10 +50,11 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { if (Build.VERSION.SDK_INT >= 27) { isChecked = TetherOffloadManager.enabled setOnPreferenceChangeListener { _, newValue -> - if (TetherOffloadManager.enabled != newValue) GlobalScope.launch(Dispatchers.Main.immediate) { + if (TetherOffloadManager.enabled != newValue) viewLifecycleOwner.lifecycleScope.launchWhenCreated { isEnabled = false try { TetherOffloadManager.setEnabled(newValue as Boolean) + } catch (_: CancellationException) { } catch (e: Exception) { Timber.w(e) SmartSnackbar.make(e).show() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt index 1917a118..93c548af 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt @@ -1,5 +1,6 @@ package be.mygod.vpnhotspot.manage +import android.annotation.TargetApi import android.content.Intent import android.os.Build import android.provider.Settings @@ -18,6 +19,7 @@ import be.mygod.vpnhotspot.databinding.ListitemInterfaceBinding import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.wifi.WifiApManager +import be.mygod.vpnhotspot.root.WifiApCommands import be.mygod.vpnhotspot.util.readableMessage import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.coroutines.Dispatchers @@ -67,7 +69,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(), inner class Data : be.mygod.vpnhotspot.manage.Data() { override val icon get() = tetherType.icon override val title get() = this@TetherManager.title - override var text: CharSequence = "" + override val text get() = error override val active get() = isStarted } @@ -75,6 +77,10 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(), abstract val title: CharSequence abstract val tetherType: TetherType open val isStarted get() = parent.enabledTypes.contains(tetherType) + protected open val error: CharSequence get() = baseError ?: "" + + protected var baseError: String? = null + private set protected abstract fun start() protected abstract fun stop() @@ -98,27 +104,53 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(), (viewHolder as ViewHolder).manager = this } - private fun getErrorMessage(iface: String): String { - return TetheringManager.tetherErrorMessage(try { - TetheringManager.getLastTetherError(iface) - } catch (e: InvocationTargetException) { - if (Build.VERSION.SDK_INT !in 24..25 || e.cause !is SecurityException) Timber.w(e) else Timber.d(e) - return e.readableMessage - }) - } - protected open fun makeErrorMessage(errored: List): CharSequence = errored - .filter { TetherType.ofInterface(it) == tetherType } - .joinToString("\n") { "$it: ${getErrorMessage(it)}" } fun updateErrorMessage(errored: List) { - data.text = makeErrorMessage(errored) - data.notifyChange() + val interested = errored.filter { TetherType.ofInterface(it) == tetherType } + baseError = if (interested.isEmpty()) null else interested.joinToString("\n") { iface -> + "$iface: " + try { + TetheringManager.tetherErrorMessage(TetheringManager.getLastTetherError(iface)) + } catch (e: InvocationTargetException) { + if (Build.VERSION.SDK_INT !in 24..25 || e.cause !is SecurityException) Timber.w(e) else Timber.d(e) + e.readableMessage + } + } } @RequiresApi(24) - class Wifi(parent: TetheringFragment) : TetherManager(parent) { + class Wifi(parent: TetheringFragment) : TetherManager(parent), DefaultLifecycleObserver, + WifiApManager.SoftApCallbackCompat { + private var failureReason: Int? = null + + init { + if (Build.VERSION.SDK_INT >= 28) parent.viewLifecycleOwner.lifecycle.addObserver(this) + } + + @TargetApi(28) + override fun onStart(owner: LifecycleOwner) { + WifiApCommands.registerSoftApCallback(this) + } + @TargetApi(28) + override fun onStop(owner: LifecycleOwner) { + WifiApCommands.unregisterSoftApCallback(this) + } + + override fun onStateChanged(state: Int, failureReason: Int) { + if (state < 10 || state > 14) { + Timber.w(Exception("Unknown state $state")) + return + } + val newReason = if (state == 14) failureReason else null + if (this.failureReason != newReason) { + this.failureReason = newReason + data.notifyChange() + } + } + override val title get() = parent.getString(R.string.tethering_manage_wifi) override val tetherType get() = TetherType.WIFI override val type get() = VIEW_TYPE_WIFI + override val error get() = listOfNotNull(failureReason?.let { WifiApManager.failureReason(it) }, + baseError).joinToString("\n") override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIFI, true, this) override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI, this::onException) @@ -134,10 +166,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(), } @RequiresApi(24) class Bluetooth(parent: TetheringFragment) : TetherManager(parent), DefaultLifecycleObserver { - private val tethering = BluetoothTethering(parent.requireContext()) { - data.text = makeErrorMessage() - data.notifyChange() - } + private val tethering = BluetoothTethering(parent.requireContext()) { data.notifyChange() } init { parent.viewLifecycleOwner.lifecycle.addObserver(this) @@ -149,15 +178,9 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(), override val tetherType get() = TetherType.BLUETOOTH override val type get() = VIEW_TYPE_BLUETOOTH override val isStarted get() = tethering.active == true - - private var baseError: CharSequence? = null - private fun makeErrorMessage(): CharSequence = listOfNotNull( + override val error get() = listOfNotNull( if (tethering.active == null) tethering.activeFailureCause?.readableMessage else null, baseError).joinToString("\n") - override fun makeErrorMessage(errored: List): CharSequence { - baseError = super.makeErrorMessage(errored).let { if (it.isEmpty()) null else it } - return makeErrorMessage() - } override fun start() = BluetoothTethering.start(this) override fun stop() { 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 b35b707d..10b6e0c3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -33,6 +33,7 @@ import be.mygod.vpnhotspot.root.RootManager import be.mygod.vpnhotspot.root.WifiApCommands import be.mygod.vpnhotspot.util.* import be.mygod.vpnhotspot.widget.SmartSnackbar +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import timber.log.Timber import java.lang.reflect.InvocationTargetException @@ -188,6 +189,8 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick if (e.targetException !is SecurityException) Timber.w(e) try { RootManager.use { it.execute(WifiApCommands.GetConfiguration()) } + } catch (_: CancellationException) { + null } catch (eRoot: Exception) { eRoot.addSuppressed(e) Timber.w(eRoot) @@ -216,6 +219,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick } catch (e: InvocationTargetException) { try { RootManager.use { it.execute(WifiApCommands.SetConfiguration(ret!!.configuration)) } + } catch (_: CancellationException) { } catch (eRoot: Exception) { eRoot.addSuppressed(e) Timber.w(eRoot) 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 b6b88953..7413a13f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt @@ -18,10 +18,7 @@ import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.root.RootManager import be.mygod.vpnhotspot.root.StartTethering import be.mygod.vpnhotspot.root.StopTethering -import be.mygod.vpnhotspot.util.Services -import be.mygod.vpnhotspot.util.broadcastReceiver -import be.mygod.vpnhotspot.util.callSuper -import be.mygod.vpnhotspot.util.ensureReceiverUnregistered +import be.mygod.vpnhotspot.util.* import com.android.dx.stock.ProxyBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -225,8 +222,6 @@ object TetheringManager { @get:RequiresApi(30) private val stopTethering by lazy { clazz.getDeclaredMethod("stopTethering", Int::class.java) } - private fun Handler?.makeExecutor() = Executor { if (this == null) it.run() else post(it) } - @Deprecated("Legacy API") @RequiresApi(24) fun startTetheringLegacy(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback, diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt index 931bee4f..2b3de011 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt @@ -3,13 +3,25 @@ package be.mygod.vpnhotspot.net.wifi import android.annotation.TargetApi import android.content.Intent import android.content.pm.PackageManager +import android.net.MacAddress import android.net.wifi.SoftApConfiguration import android.net.wifi.WifiManager import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.annotation.RequiresApi +import androidx.collection.SparseArrayCompat import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat import be.mygod.vpnhotspot.util.Services +import be.mygod.vpnhotspot.util.callSuper +import be.mygod.vpnhotspot.util.makeExecutor +import timber.log.Timber +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.concurrent.Executor object WifiApManager { /** @@ -48,9 +60,153 @@ object WifiApManager { setWifiApConfiguration(Services.wifi, value.toWifiConfiguration()) } else setSoftApConfiguration(Services.wifi, value.toPlatform())) as Boolean + @RequiresApi(28) + interface SoftApCallbackCompat { + /** + * Called when soft AP state changes. + * + * @param state new new AP state. One of {@link #WIFI_AP_STATE_DISABLED}, + * {@link #WIFI_AP_STATE_DISABLING}, {@link #WIFI_AP_STATE_ENABLED}, + * {@link #WIFI_AP_STATE_ENABLING}, {@link #WIFI_AP_STATE_FAILED} + * @param failureReason reason when in failed state. One of + * {@link #SAP_START_FAILURE_GENERAL}, {@link #SAP_START_FAILURE_NO_CHANNEL} + */ + fun onStateChanged(state: Int, failureReason: Int) { } + + /** + * Called when number of connected clients to soft AP changes. + * + * @param numClients number of connected clients + */ + @Deprecated("onConnectedClientsChanged") + fun onNumClientsChanged(numClients: Int) { } + + @RequiresApi(30) + fun onConnectedClientsChanged(clients: List) { + @Suppress("DEPRECATION") + onNumClientsChanged(clients.size) + } + + @RequiresApi(30) + fun onInfoChanged(frequency: Int, bandwidth: Int) { } + + @RequiresApi(30) + fun onCapabilityChanged(maxSupportedClients: Int, supportedFeatures: Long) { } + + @RequiresApi(30) + fun onBlockedClientConnecting(client: MacAddress, blockedReason: Int) { } + } + private val startFailures29 = arrayOf("SAP_START_FAILURE_GENERAL", "SAP_START_FAILURE_NO_CHANNEL") + private val startFailures by lazy { + SparseArrayCompat().apply { + for (field in WifiManager::class.java.declaredFields) try { + // all SAP_START_FAILURE_* are system-api since API 30 + if (field.name.startsWith("SAP_START_FAILURE_")) put(field.get(null) as Int, field.name) + } catch (e: Exception) { + Timber.w(e) + } + } + } + fun failureReason(reason: Int): String { + if (Build.VERSION.SDK_INT >= 30) try { + startFailures.get(reason)?.let { return it } + } catch (e: ReflectiveOperationException) { + Timber.w(e) + } + return startFailures29.getOrNull(reason) ?: app.getString(R.string.failure_reason_unknown, reason) + } + + private val interfaceSoftApCallback by lazy { Class.forName("android.net.wifi.WifiManager\$SoftApCallback") } + private val registerSoftApCallback by lazy { + val parameters = if (Build.VERSION.SDK_INT >= 30) { + arrayOf(Executor::class.java, interfaceSoftApCallback) + } else arrayOf(interfaceSoftApCallback, Handler::class.java) + WifiManager::class.java.getDeclaredMethod("registerSoftApCallback", *parameters) + } + private val unregisterSoftApCallback by lazy { + WifiManager::class.java.getDeclaredMethod("unregisterSoftApCallback", interfaceSoftApCallback) + } + private val getMacAddress by lazy { + Class.forName("android.net.wifi.WifiClient").getDeclaredMethod("getMacAddress") + } + private val classSoftApInfo by lazy { Class.forName("android.net.wifi.SoftApInfo") } + private val getFrequency by lazy { classSoftApInfo.getDeclaredMethod("getFrequency") } + private val getBandwidth by lazy { classSoftApInfo.getDeclaredMethod("getBandwidth") } + private val classSoftApCapability by lazy { Class.forName("android.net.wifi.SoftApCapability") } + private val getMaxSupportedClients by lazy { classSoftApCapability.getDeclaredMethod("getMaxSupportedClients") } + private val areFeaturesSupported by lazy { + classSoftApCapability.getDeclaredMethod("areFeaturesSupported", Long::class.java) + } + @RequiresApi(28) + fun registerSoftApCallback(callback: SoftApCallbackCompat, executor: Executor): Any { + val proxy = Proxy.newProxyInstance(interfaceSoftApCallback.classLoader, + arrayOf(interfaceSoftApCallback), object : InvocationHandler { + override fun invoke(proxy: Any, method: Method, args: Array?): Any? { + return if (Build.VERSION.SDK_INT >= 30) invokeActual(proxy, method, args) else { + executor.execute { invokeActual(proxy, method, args) } + null // no return value as of API 30 + } + } + + private fun invokeActual(proxy: Any, method: Method, args: Array?): Any? { + val noArgs = args?.size ?: 0 + return when (val name = method.name) { + "onStateChanged" -> { + if (noArgs != 2) Timber.w("Unexpected args for $name: $args") + callback.onStateChanged(args!![0] as Int, args[1] as Int) + } + "onNumClientsChanged" -> @Suppress("DEPRECATION") { + if (Build.VERSION.SDK_INT >= 30) Timber.w(Exception("Unexpected onNumClientsChanged")) + if (noArgs != 1) Timber.w("Unexpected args for $name: $args") + callback.onNumClientsChanged(args!![0] as Int) + } + "onConnectedClientsChanged" -> @TargetApi(30) { + if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onConnectedClientsChanged")) + if (noArgs != 1) Timber.w("Unexpected args for $name: $args") + callback.onConnectedClientsChanged((args!![0] as Iterable<*>) + .map { getMacAddress(it) as MacAddress }) + } + "onInfoChanged" -> @TargetApi(30) { + if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onInfoChanged")) + if (noArgs != 1) Timber.w("Unexpected args for $name: $args") + val softApInfo = args!![0] + callback.onInfoChanged(getFrequency(softApInfo) as Int, getBandwidth(softApInfo) as Int) + } + "onCapabilityChanged" -> @TargetApi(30) { + if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onCapabilityChanged")) + if (noArgs != 1) Timber.w("Unexpected args for $name: $args") + val softApCapability = args!![0] + var supportedFeatures = 0L + var probe = 1L + while (probe != 0L) { + if (areFeaturesSupported(softApCapability, probe) as Boolean) { + supportedFeatures = supportedFeatures or probe + } + probe += probe + } + callback.onCapabilityChanged(getMaxSupportedClients(softApCapability) as Int, supportedFeatures) + } + "onBlockedClientConnecting" -> @TargetApi(30) { + if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onBlockedClientConnecting")) + if (noArgs != 2) Timber.w("Unexpected args for $name: $args") + callback.onBlockedClientConnecting(getMacAddress(args!![0]) as MacAddress, args[1] as Int) + } + else -> callSuper(interfaceSoftApCallback, proxy, method, args) + } + } + }) + if (Build.VERSION.SDK_INT >= 30) { + registerSoftApCallback(Services.wifi, executor, proxy) + } else registerSoftApCallback(Services.wifi, proxy, null) + return proxy + } + @RequiresApi(28) + fun unregisterSoftApCallback(key: Any) = unregisterSoftApCallback(Services.wifi, key) + private val cancelLocalOnlyHotspotRequest by lazy { WifiManager::class.java.getDeclaredMethod("cancelLocalOnlyHotspotRequest") } + @RequiresApi(26) fun cancelLocalOnlyHotspotRequest() = cancelLocalOnlyHotspotRequest(Services.wifi) @Suppress("DEPRECATION") diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt index 3676b606..c84bf043 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt @@ -1,6 +1,7 @@ package be.mygod.vpnhotspot.root import android.os.Parcelable +import android.util.Log import be.mygod.librootkotlinx.RootCommandNoResult import be.mygod.librootkotlinx.RootServer import be.mygod.librootkotlinx.RootSession @@ -16,6 +17,16 @@ object RootManager : RootSession() { class RootInit : RootCommandNoResult { override suspend fun execute(): Parcelable? { RootServer.DEBUG = BuildConfig.DEBUG + Timber.plant(object : Timber.DebugTree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (t == null) { + Log.println(priority, tag, message) + } else { + Log.println(priority, tag, message) + Log.d(tag, message, t) + } + } + }) Services.init(RootJava.getSystemContext()) return null } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt index ee3527ec..d8887991 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt @@ -1,12 +1,162 @@ package be.mygod.vpnhotspot.root +import android.net.MacAddress +import android.os.Parcelable +import androidx.annotation.RequiresApi import be.mygod.librootkotlinx.ParcelableBoolean import be.mygod.librootkotlinx.RootCommand +import be.mygod.librootkotlinx.RootCommandChannel import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat import be.mygod.vpnhotspot.net.wifi.WifiApManager +import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.android.parcel.Parcelize +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.channels.produce +import timber.log.Timber +import java.util.concurrent.Executor object WifiApCommands { + @RequiresApi(28) + sealed class SoftApCallbackParcel : Parcelable { + abstract fun dispatch(callback: WifiApManager.SoftApCallbackCompat) + + @Parcelize + data class OnStateChanged(val state: Int, val failureReason: Int) : SoftApCallbackParcel() { + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onStateChanged(state, failureReason) + } + @Parcelize + data class OnNumClientsChanged(val numClients: Int) : SoftApCallbackParcel() { + @Suppress("DEPRECATION") + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onNumClientsChanged(numClients) + } + @Parcelize + @RequiresApi(30) + data class OnConnectedClientsChanged(val clients: List) : SoftApCallbackParcel() { + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onConnectedClientsChanged(clients) + } + @Parcelize + @RequiresApi(30) + data class OnInfoChanged(val frequency: Int, val bandwidth: Int) : SoftApCallbackParcel() { + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onInfoChanged(frequency, bandwidth) + } + @Parcelize + @RequiresApi(30) + data class OnCapabilityChanged(val maxSupportedClients: Int, + val supportedFeatures: Long) : SoftApCallbackParcel() { + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onCapabilityChanged(maxSupportedClients, supportedFeatures) + } + @Parcelize + @RequiresApi(30) + data class OnBlockedClientConnecting(val client: MacAddress, val blockedReason: Int) : SoftApCallbackParcel() { + override fun dispatch(callback: WifiApManager.SoftApCallbackCompat) = + callback.onBlockedClientConnecting(client, blockedReason) + } + } + + @Parcelize + @RequiresApi(28) + class RegisterSoftApCallback : RootCommandChannel { + override fun create(scope: CoroutineScope) = scope.produce(capacity = capacity) { + val finish = CompletableDeferred() + val key = WifiApManager.registerSoftApCallback(object : WifiApManager.SoftApCallbackCompat { + private fun push(parcel: SoftApCallbackParcel) = check(try { + offer(parcel) + } catch (closed: Throwable) { + finish.completeExceptionally(closed) + true + }) + + override fun onStateChanged(state: Int, failureReason: Int) = + push(SoftApCallbackParcel.OnStateChanged(state, failureReason)) + @Suppress("OverridingDeprecatedMember") + override fun onNumClientsChanged(numClients: Int) = + push(SoftApCallbackParcel.OnNumClientsChanged(numClients)) + @RequiresApi(30) + override fun onConnectedClientsChanged(clients: List) = + push(SoftApCallbackParcel.OnConnectedClientsChanged(clients)) + @RequiresApi(30) + override fun onInfoChanged(frequency: Int, bandwidth: Int) = + push(SoftApCallbackParcel.OnInfoChanged(frequency, bandwidth)) + @RequiresApi(30) + override fun onCapabilityChanged(maxSupportedClients: Int, supportedFeatures: Long) = + push(SoftApCallbackParcel.OnCapabilityChanged(maxSupportedClients, supportedFeatures)) + @RequiresApi(30) + override fun onBlockedClientConnecting(client: MacAddress, blockedReason: Int) = + push(SoftApCallbackParcel.OnBlockedClientConnecting(client, blockedReason)) + }, Executor { + scope.launch { + try { + it.run() + } catch (e: Throwable) { + finish.completeExceptionally(e) + } + } + }) + try { + finish.await() + } finally { + WifiApManager.unregisterSoftApCallback(key) + } + } + } + + private data class AutoFiringCallbacks( + var state: SoftApCallbackParcel.OnStateChanged? = null, + var numClients: SoftApCallbackParcel.OnNumClientsChanged? = null, + var connectedClients: SoftApCallbackParcel.OnConnectedClientsChanged? = null, + var info: SoftApCallbackParcel.OnInfoChanged? = null, + var capability: SoftApCallbackParcel.OnCapabilityChanged? = null) { + fun toSequence() = sequenceOf(state, numClients, connectedClients, info, capability) + } + + private val callbacks = mutableSetOf() + private val lastCallback = AutoFiringCallbacks() + private var rootCallbackJob: Job? = null + @RequiresApi(28) + private suspend fun handleChannel(channel: ReceiveChannel) = channel.consumeEach { parcel -> + when (parcel) { + is SoftApCallbackParcel.OnStateChanged -> synchronized(callbacks) { lastCallback.state = parcel } + is SoftApCallbackParcel.OnNumClientsChanged -> synchronized(callbacks) { lastCallback.numClients = parcel } + is SoftApCallbackParcel.OnConnectedClientsChanged -> synchronized(callbacks) { + lastCallback.connectedClients = parcel + } + is SoftApCallbackParcel.OnInfoChanged -> synchronized(callbacks) { lastCallback.info = parcel } + is SoftApCallbackParcel.OnCapabilityChanged -> synchronized(callbacks) { lastCallback.capability = parcel } + } + for (callback in synchronized(callbacks) { callbacks }) parcel.dispatch(callback) + } + @RequiresApi(28) + fun registerSoftApCallback(callback: WifiApManager.SoftApCallbackCompat) = synchronized(callbacks) { + val wasEmpty = callbacks.isEmpty() + callbacks.add(callback) + if (wasEmpty) { + rootCallbackJob = GlobalScope.launch { + try { + RootManager.use { server -> handleChannel(server.create(RegisterSoftApCallback(), this)) } + } catch (_: CancellationException) { + } catch (e: Exception) { + Timber.w(e) + SmartSnackbar.make(e).show() + } + } + lastCallback + } else null + }?.toSequence()?.forEach { it?.dispatch(callback) } + @RequiresApi(28) + fun unregisterSoftApCallback(callback: WifiApManager.SoftApCallbackCompat) = synchronized(callbacks) { + if (callbacks.remove(callback) && callbacks.isEmpty()) { + rootCallbackJob!!.cancel() + rootCallbackJob = null + } + } + @Parcelize class GetConfiguration : RootCommand { override suspend fun execute() = WifiApManager.configuration diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt index dca37f17..b696673d 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -5,6 +5,7 @@ import android.annotation.TargetApi import android.content.* import android.net.InetAddresses import android.os.Build +import android.os.Handler import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder @@ -28,6 +29,7 @@ import java.lang.reflect.Method import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException +import java.util.concurrent.Executor val Throwable.readableMessage: String get() = if (this is InvocationTargetException) { targetException.readableMessage @@ -49,6 +51,8 @@ fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) { } catch (_: IllegalArgumentException) { } } +fun Handler?.makeExecutor() = Executor { if (this == null) it.run() else post(it) } + fun DialogFragment.showAllowingStateLoss(manager: FragmentManager, tag: String? = null) { if (!manager.isStateSaved) show(manager, tag) }