diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 04697d5f..7761059c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -4,11 +4,15 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler import androidx.annotation.RequiresApi import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor +import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor import be.mygod.vpnhotspot.util.StickyEvent1 import be.mygod.vpnhotspot.util.broadcastReceiver import be.mygod.vpnhotspot.widget.SmartSnackbar @@ -44,6 +48,9 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { */ override val coroutineContext = newSingleThreadContext("LocalOnlyHotspotService") + Job() private var routingManager: RoutingManager? = null + private val handler = Handler() + @RequiresApi(28) + private var timeoutMonitor: TetherTimeoutMonitor? = null private var receiverRegistered = false private val receiver = broadcastReceiver { _, intent -> val ifaces = intent.localOnlyTetheredIfaces ?: return@broadcastReceiver @@ -81,6 +88,8 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { if (reservation == null) onFailed(-2) else { this@LocalOnlyHotspotService.reservation = reservation if (!receiverRegistered) { + if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor( + this@LocalOnlyHotspotService, handler, reservation::close) registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) receiverRegistered = true } @@ -123,6 +132,11 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { stopSelf() } + override fun onIpNeighbourAvailable(neighbours: List) { + super.onIpNeighbourAvailable(neighbours) + if (Build.VERSION.SDK_INT >= 28) timeoutMonitor?.onClientsChanged(neighbours.isEmpty()) + } + override fun onDestroy() { binder.stop() unregisterReceiver(true) @@ -130,15 +144,19 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { } private fun unregisterReceiver(exit: Boolean = false) { + if (receiverRegistered) { + unregisterReceiver(receiver) + IpNeighbourMonitor.unregisterCallback(this) + if (Build.VERSION.SDK_INT >= 28) { + timeoutMonitor?.close() + timeoutMonitor = null + } + receiverRegistered = false + } launch { routingManager?.destroy() routingManager = null if (exit) cancel() } - if (receiverRegistered) { - unregisterReceiver(receiver) - IpNeighbourMonitor.unregisterCallback(this) - receiverRegistered = false - } } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 5d79feb5..ce242552 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -10,10 +10,12 @@ import android.net.wifi.p2p.* import android.os.Build import android.os.Handler import android.os.Looper +import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.content.edit import androidx.core.content.getSystemService import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId @@ -87,6 +89,9 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene set(value) { field = value groupChanged(value) + if (Build.VERSION.SDK_INT >= 28) value?.clientList?.let { + timeoutMonitor?.onClientsChanged(it.isEmpty()) + } } val groupChanged = StickyEvent1 { group } @Deprecated("Not initialized and no use at all since API 29") @@ -118,6 +123,8 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene private var channel: WifiP2pManager.Channel? = null private val binder = Binder() private val handler = Handler() + @RequiresApi(28) + private var timeoutMonitor: TetherTimeoutMonitor? = null private var receiverRegistered = false private val receiver = broadcastReceiver { _, intent -> when (intent.action) { @@ -348,6 +355,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene * startService Step 3 */ private fun doStartLocked(group: WifiP2pGroup) { + if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor(this, handler, binder::shutdown) binder.group = group if (persistNextGroup) { networkName = group.networkName @@ -389,6 +397,10 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene } private fun cleanLocked() { unregisterReceiver() + if (Build.VERSION.SDK_INT >= 28) { + timeoutMonitor?.close() + timeoutMonitor = null + } routingManager?.destroy() routingManager = null status = Status.IDLE diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt new file mode 100644 index 00000000..e1bbc715 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt @@ -0,0 +1,95 @@ +package be.mygod.vpnhotspot.net.monitor + +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.database.ContentObserver +import android.os.BatteryManager +import android.os.Handler +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.os.postDelayed +import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.util.broadcastReceiver +import be.mygod.vpnhotspot.util.intentFilter +import timber.log.Timber +import java.lang.IllegalArgumentException + +@RequiresApi(28) +class TetherTimeoutMonitor(private val context: Context, private val handler: Handler, private val onTimeout: () -> Unit): + ContentObserver(handler), AutoCloseable { + /** + * config_wifi_framework_soft_ap_timeout_delay was introduced in Android 9. + * + * Source: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/87ed136/service/java/com/android/server/wifi/SoftApManager.java + */ + companion object { + /** + * Whether soft AP will shut down after a timeout period when no devices are connected. + * + * Type: int (0 for false, 1 for true) + */ + private const val SOFT_AP_TIMEOUT_ENABLED = "soft_ap_timeout_enabled" + /** + * Minimum limit to use for timeout delay if the value from overlay setting is too small. + */ + private const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes + + private val enabled get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1 + private val timeout by lazy { + app.resources.getInteger(Resources.getSystem().getIdentifier( + "config_wifi_framework_soft_ap_timeout_delay", "integer", "android")).let { delay -> + if (delay < MIN_SOFT_AP_TIMEOUT_DELAY_MS) { + Timber.w("Overriding timeout delay with minimum limit value: $delay < $MIN_SOFT_AP_TIMEOUT_DELAY_MS") + MIN_SOFT_AP_TIMEOUT_DELAY_MS + } else delay + } + } + } + + private var charging = when (context.registerReceiver(null, intentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) { + BatteryManager.BATTERY_STATUS_CHARGING, BatteryManager.BATTERY_STATUS_FULL -> true + null, -1 -> false.also { Timber.w(Exception("Battery status not found")) } + else -> false + } + private var noClient = true + private var timeoutPending = false + + private val receiver = broadcastReceiver { _, intent -> + charging = when (intent.action) { + Intent.ACTION_POWER_CONNECTED -> true + Intent.ACTION_POWER_DISCONNECTED -> false + else -> throw IllegalArgumentException("Invalid intent.action") + } + onChange(true) + }.also { + context.registerReceiver(it, intentFilter(Intent.ACTION_POWER_CONNECTED, Intent.ACTION_POWER_DISCONNECTED)) + context.contentResolver.registerContentObserver(Settings.Global.getUriFor(SOFT_AP_TIMEOUT_ENABLED), true, this) + } + + override fun close() { + context.unregisterReceiver(receiver) + context.contentResolver.unregisterContentObserver(this) + } + + fun onClientsChanged(noClient: Boolean) { + this.noClient = noClient + onChange(true) + } + + override fun onChange(selfChange: Boolean) { + // super.onChange(selfChange) should not do anything + if (enabled && noClient && !charging) { + if (!timeoutPending) { + handler.postDelayed(timeout.toLong(), this, onTimeout) + timeoutPending = true + } + } else { + if (timeoutPending) { + handler.removeCallbacksAndMessages(this) + timeoutPending = false + } + } + } +} 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 02dd7b96..4d129b70 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -58,11 +58,7 @@ fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastR override fun onReceive(context: Context, intent: Intent) = receiver(context, intent) } -fun intentFilter(vararg actions: String): IntentFilter { - val result = IntentFilter() - actions.forEach { result.addAction(it) } - return result -} +fun intentFilter(vararg actions: String) = IntentFilter().also { actions.forEach(it::addAction) } @BindingAdapter("android:src") fun setImageResource(imageView: ImageView, @DrawableRes resource: Int) = imageView.setImageResource(resource) @@ -74,11 +70,11 @@ fun setVisibility(view: View, value: Boolean) { fun makeIpSpan(ip: InetAddress) = ip.hostAddress.let { // exclude all bogon IP addresses supported by Android APIs - if (app.hasTouch && !(ip.isMulticastAddress || ip.isAnyLocalAddress || ip.isLoopbackAddress || - ip.isLinkLocalAddress || ip.isSiteLocalAddress || ip.isMCGlobal || ip.isMCNodeLocal || - ip.isMCLinkLocal || ip.isMCSiteLocal || ip.isMCOrgLocal)) SpannableString(it).apply { + if (!app.hasTouch || ip.isMulticastAddress || ip.isAnyLocalAddress || ip.isLoopbackAddress || + ip.isLinkLocalAddress || ip.isSiteLocalAddress || ip.isMCGlobal || ip.isMCNodeLocal || + ip.isMCLinkLocal || ip.isMCSiteLocal || ip.isMCOrgLocal) it else SpannableString(it).apply { setSpan(CustomTabsUrlSpan("https://ipinfo.io/$it"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } else it + } } fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply { setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)