Add tether timeout monitor for Android 9+
This commit is contained in:
@@ -4,11 +4,15 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
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
|
||||||
import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
|
import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
|
||||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
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.StickyEvent1
|
||||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
@@ -44,6 +48,9 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
|||||||
*/
|
*/
|
||||||
override val coroutineContext = newSingleThreadContext("LocalOnlyHotspotService") + Job()
|
override val coroutineContext = newSingleThreadContext("LocalOnlyHotspotService") + Job()
|
||||||
private var routingManager: RoutingManager? = null
|
private var routingManager: RoutingManager? = null
|
||||||
|
private val handler = Handler()
|
||||||
|
@RequiresApi(28)
|
||||||
|
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
||||||
private var receiverRegistered = false
|
private var receiverRegistered = false
|
||||||
private val receiver = broadcastReceiver { _, intent ->
|
private val receiver = broadcastReceiver { _, intent ->
|
||||||
val ifaces = intent.localOnlyTetheredIfaces ?: return@broadcastReceiver
|
val ifaces = intent.localOnlyTetheredIfaces ?: return@broadcastReceiver
|
||||||
@@ -81,6 +88,8 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
|||||||
if (reservation == null) onFailed(-2) else {
|
if (reservation == null) onFailed(-2) else {
|
||||||
this@LocalOnlyHotspotService.reservation = reservation
|
this@LocalOnlyHotspotService.reservation = reservation
|
||||||
if (!receiverRegistered) {
|
if (!receiverRegistered) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor(
|
||||||
|
this@LocalOnlyHotspotService, handler, reservation::close)
|
||||||
registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
||||||
receiverRegistered = true
|
receiverRegistered = true
|
||||||
}
|
}
|
||||||
@@ -123,6 +132,11 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onIpNeighbourAvailable(neighbours: List<IpNeighbour>) {
|
||||||
|
super.onIpNeighbourAvailable(neighbours)
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor?.onClientsChanged(neighbours.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
binder.stop()
|
binder.stop()
|
||||||
unregisterReceiver(true)
|
unregisterReceiver(true)
|
||||||
@@ -130,15 +144,19 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun unregisterReceiver(exit: Boolean = false) {
|
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 {
|
launch {
|
||||||
routingManager?.destroy()
|
routingManager?.destroy()
|
||||||
routingManager = null
|
routingManager = null
|
||||||
if (exit) cancel()
|
if (exit) cancel()
|
||||||
}
|
}
|
||||||
if (receiverRegistered) {
|
|
||||||
unregisterReceiver(receiver)
|
|
||||||
IpNeighbourMonitor.unregisterCallback(this)
|
|
||||||
receiverRegistered = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import android.net.wifi.p2p.*
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
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
|
||||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
|
||||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId
|
||||||
@@ -87,6 +89,9 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
|||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
groupChanged(value)
|
groupChanged(value)
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) value?.clientList?.let {
|
||||||
|
timeoutMonitor?.onClientsChanged(it.isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val groupChanged = StickyEvent1 { group }
|
val groupChanged = StickyEvent1 { group }
|
||||||
@Deprecated("Not initialized and no use at all since API 29")
|
@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 var channel: WifiP2pManager.Channel? = null
|
||||||
private val binder = Binder()
|
private val binder = Binder()
|
||||||
private val handler = Handler()
|
private val handler = Handler()
|
||||||
|
@RequiresApi(28)
|
||||||
|
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
||||||
private var receiverRegistered = false
|
private var receiverRegistered = false
|
||||||
private val receiver = broadcastReceiver { _, intent ->
|
private val receiver = broadcastReceiver { _, intent ->
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
@@ -348,6 +355,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
|||||||
* startService Step 3
|
* startService Step 3
|
||||||
*/
|
*/
|
||||||
private fun doStartLocked(group: WifiP2pGroup) {
|
private fun doStartLocked(group: WifiP2pGroup) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor(this, handler, binder::shutdown)
|
||||||
binder.group = group
|
binder.group = group
|
||||||
if (persistNextGroup) {
|
if (persistNextGroup) {
|
||||||
networkName = group.networkName
|
networkName = group.networkName
|
||||||
@@ -389,6 +397,10 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
|||||||
}
|
}
|
||||||
private fun cleanLocked() {
|
private fun cleanLocked() {
|
||||||
unregisterReceiver()
|
unregisterReceiver()
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
timeoutMonitor?.close()
|
||||||
|
timeoutMonitor = null
|
||||||
|
}
|
||||||
routingManager?.destroy()
|
routingManager?.destroy()
|
||||||
routingManager = null
|
routingManager = null
|
||||||
status = Status.IDLE
|
status = Status.IDLE
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,11 +58,7 @@ fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastR
|
|||||||
override fun onReceive(context: Context, intent: Intent) = receiver(context, intent)
|
override fun onReceive(context: Context, intent: Intent) = receiver(context, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun intentFilter(vararg actions: String): IntentFilter {
|
fun intentFilter(vararg actions: String) = IntentFilter().also { actions.forEach(it::addAction) }
|
||||||
val result = IntentFilter()
|
|
||||||
actions.forEach { result.addAction(it) }
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@BindingAdapter("android:src")
|
@BindingAdapter("android:src")
|
||||||
fun setImageResource(imageView: ImageView, @DrawableRes resource: Int) = imageView.setImageResource(resource)
|
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 {
|
fun makeIpSpan(ip: InetAddress) = ip.hostAddress.let {
|
||||||
// exclude all bogon IP addresses supported by Android APIs
|
// exclude all bogon IP addresses supported by Android APIs
|
||||||
if (app.hasTouch && !(ip.isMulticastAddress || ip.isAnyLocalAddress || ip.isLoopbackAddress ||
|
if (!app.hasTouch || ip.isMulticastAddress || ip.isAnyLocalAddress || ip.isLoopbackAddress ||
|
||||||
ip.isLinkLocalAddress || ip.isSiteLocalAddress || ip.isMCGlobal || ip.isMCNodeLocal ||
|
ip.isLinkLocalAddress || ip.isSiteLocalAddress || ip.isMCGlobal || ip.isMCNodeLocal ||
|
||||||
ip.isMCLinkLocal || ip.isMCSiteLocal || ip.isMCOrgLocal)) SpannableString(it).apply {
|
ip.isMCLinkLocal || ip.isMCSiteLocal || ip.isMCOrgLocal) it else SpannableString(it).apply {
|
||||||
setSpan(CustomTabsUrlSpan("https://ipinfo.io/$it"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
setSpan(CustomTabsUrlSpan("https://ipinfo.io/$it"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
} else it
|
}
|
||||||
}
|
}
|
||||||
fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply {
|
fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply {
|
||||||
setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
|||||||
Reference in New Issue
Block a user