Make timeout configurable
ContentObserver is deprecated for this feature. Additionally repeater supports auto shutdown in older API levels as well, while temporary hotspot only auto shutdown in API 28-29 for now.
This commit is contained in:
@@ -54,7 +54,6 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
private val dispatcher = newSingleThreadContext("LocalOnlyHotspotService")
|
||||
override val coroutineContext = dispatcher + Job()
|
||||
private var routingManager: RoutingManager? = null
|
||||
@RequiresApi(28)
|
||||
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
@@ -86,8 +85,11 @@ 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, reservation::close)
|
||||
val configuration = binder.configuration!!
|
||||
if (Build.VERSION.SDK_INT < 30 && configuration.isAutoShutdownEnabled) {
|
||||
timeoutMonitor = TetherTimeoutMonitor(configuration.shutdownTimeoutMillis,
|
||||
coroutineContext) { reservation.close() }
|
||||
}
|
||||
registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
private const val KEY_PASSPHRASE = "service.repeater.passphrase"
|
||||
private const val KEY_OPERATING_BAND = "service.repeater.band.v2"
|
||||
private const val KEY_OPERATING_CHANNEL = "service.repeater.oc"
|
||||
private const val KEY_AUTO_SHUTDOWN = "service.repeater.autoShutdown"
|
||||
private const val KEY_SHUTDOWN_TIMEOUT = "service.repeater.shutdownTimeout"
|
||||
private const val KEY_DEVICE_ADDRESS = "service.repeater.mac"
|
||||
/**
|
||||
* Placeholder for bypassing networkName check.
|
||||
@@ -83,6 +85,12 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
return if (result > 0) result else 0
|
||||
}
|
||||
set(value) = app.pref.edit { putString(KEY_OPERATING_CHANNEL, value.toString()) }
|
||||
var isAutoShutdownEnabled: Boolean
|
||||
get() = app.pref.getBoolean(KEY_AUTO_SHUTDOWN, false)
|
||||
set(value) = app.pref.edit { putBoolean(KEY_AUTO_SHUTDOWN, value) }
|
||||
var shutdownTimeoutMillis: Long
|
||||
get() = app.pref.getLong(KEY_SHUTDOWN_TIMEOUT, 0)
|
||||
set(value) = app.pref.edit { putLong(KEY_SHUTDOWN_TIMEOUT, value) }
|
||||
var deviceAddress: MacAddressCompat?
|
||||
get() = try {
|
||||
MacAddressCompat(app.pref.getLong(KEY_DEVICE_ADDRESS, MacAddressCompat.ANY_ADDRESS.addr)).run {
|
||||
@@ -190,7 +198,6 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
private val p2pManager get() = Services.p2p!!
|
||||
private var channel: WifiP2pManager.Channel? = null
|
||||
private val binder = Binder()
|
||||
@RequiresApi(28)
|
||||
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
@@ -422,7 +429,9 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
* startService Step 3
|
||||
*/
|
||||
private fun doStartLocked(group: WifiP2pGroup) {
|
||||
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor(this, binder::shutdown)
|
||||
if (isAutoShutdownEnabled) timeoutMonitor = TetherTimeoutMonitor(shutdownTimeoutMillis, coroutineContext) {
|
||||
binder.shutdown()
|
||||
}
|
||||
binder.group = group
|
||||
if (persistNextGroup) {
|
||||
networkName = group.networkName
|
||||
|
||||
@@ -188,7 +188,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
val networkName = RepeaterService.networkName
|
||||
val passphrase = RepeaterService.passphrase
|
||||
if (networkName != null && passphrase != null) {
|
||||
return SoftApConfigurationCompat.empty().apply {
|
||||
return SoftApConfigurationCompat().apply {
|
||||
ssid = networkName
|
||||
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used
|
||||
this.passphrase = passphrase
|
||||
@@ -199,7 +199,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
}
|
||||
} else binder?.let { binder ->
|
||||
val group = binder.group ?: binder.fetchPersistentGroup().let { binder.group }
|
||||
if (group != null) return SoftApConfigurationCompat.empty().run {
|
||||
if (group != null) return SoftApConfigurationCompat().run {
|
||||
ssid = group.networkName
|
||||
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used
|
||||
band = SoftApConfigurationCompat.BAND_ANY
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
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.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.postDelayed
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||
import be.mygod.vpnhotspot.util.ensureReceiverUnregistered
|
||||
import be.mygod.vpnhotspot.util.intentFilter
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@RequiresApi(28)
|
||||
class TetherTimeoutMonitor(private val context: Context, private val onTimeout: () -> Unit,
|
||||
private val handler: Handler = Handler(Looper.getMainLooper())) :
|
||||
ContentObserver(handler), AutoCloseable {
|
||||
class TetherTimeoutMonitor(private val timeout: Long = 0,
|
||||
private val context: CoroutineContext = Dispatchers.Main.immediate,
|
||||
private val onTimeout: () -> Unit) : AutoCloseable {
|
||||
/**
|
||||
* config_wifi_framework_soft_ap_timeout_delay was introduced in Android 9.
|
||||
*
|
||||
@@ -37,18 +28,19 @@ class TetherTimeoutMonitor(private val context: Context, private val onTimeout:
|
||||
/**
|
||||
* Minimum limit to use for timeout delay if the value from overlay setting is too small.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
|
||||
private const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
|
||||
|
||||
@Deprecated("Use SoftApConfigurationCompat instead")
|
||||
@get:RequiresApi(28)
|
||||
@set:RequiresApi(28)
|
||||
var enabled
|
||||
get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1
|
||||
set(value) {
|
||||
// TODO: WRITE_SECURE_SETTINGS permission
|
||||
check(Settings.Global.putInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0))
|
||||
}
|
||||
val timeout by lazy {
|
||||
val delay = try {
|
||||
val defaultTimeout: Int get() {
|
||||
val delay = if (Build.VERSION.SDK_INT >= 28) try {
|
||||
if (Build.VERSION.SDK_INT < 30) Resources.getSystem().run {
|
||||
getInteger(getIdentifier("config_wifi_framework_soft_ap_timeout_delay", "integer", "android"))
|
||||
} else app.packageManager.getResourcesForApplication(WifiApManager.resolvedActivity.activityInfo
|
||||
@@ -59,57 +51,27 @@ class TetherTimeoutMonitor(private val context: Context, private val onTimeout:
|
||||
} catch (e: Resources.NotFoundException) {
|
||||
Timber.w(e)
|
||||
MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < 30 && delay < MIN_SOFT_AP_TIMEOUT_DELAY_MS) {
|
||||
} else MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
return if (Build.VERSION.SDK_INT < 30 && 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)
|
||||
}
|
||||
private var timeoutJob: Job? = null
|
||||
|
||||
override fun close() {
|
||||
context.ensureReceiverUnregistered(receiver)
|
||||
context.contentResolver.unregisterContentObserver(this)
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (!noClient) close() else if (timeoutJob == null) timeoutJob = GlobalScope.launch(context) {
|
||||
delay(if (timeout == 0L) defaultTimeout.toLong() else timeout)
|
||||
onTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +222,6 @@ data class SoftApConfigurationCompat(
|
||||
}
|
||||
},
|
||||
if (Build.VERSION.SDK_INT >= 28) TetherTimeoutMonitor.enabled else false,
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
TetherTimeoutMonitor.timeout.toLong()
|
||||
} else TetherTimeoutMonitor.MIN_SOFT_AP_TIMEOUT_DELAY_MS.toLong(),
|
||||
underlying = this)
|
||||
|
||||
@RequiresApi(30)
|
||||
@@ -244,12 +241,6 @@ data class SoftApConfigurationCompat(
|
||||
getBlockedClientList(this) as List<MacAddress?>,
|
||||
getAllowedClientList(this) as List<MacAddress?>,
|
||||
this)
|
||||
|
||||
fun empty() = SoftApConfigurationCompat(
|
||||
isAutoShutdownEnabled = if (Build.VERSION.SDK_INT >= 28) TetherTimeoutMonitor.enabled else false,
|
||||
shutdownTimeoutMillis = if (Build.VERSION.SDK_INT >= 28) {
|
||||
TetherTimeoutMonitor.timeout.toLong()
|
||||
} else TetherTimeoutMonitor.MIN_SOFT_AP_TIMEOUT_DELAY_MS.toLong())
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -25,6 +25,7 @@ import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.databinding.DialogWifiApBinding
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.util.QRCodeDialog
|
||||
import be.mygod.vpnhotspot.util.readableMessage
|
||||
import be.mygod.vpnhotspot.util.showAllowingStateLoss
|
||||
@@ -109,6 +110,10 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
isHiddenSsid = dialogView.hiddenSsid.isChecked
|
||||
}
|
||||
if (full) {
|
||||
isAutoShutdownEnabled = dialogView.autoShutdown.isChecked
|
||||
shutdownTimeoutMillis = dialogView.timeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) 0 else text.toString().toLong()
|
||||
}
|
||||
val bandOption = dialogView.band.selectedItem as BandOption
|
||||
band = bandOption.band
|
||||
channel = bandOption.channel
|
||||
@@ -141,6 +146,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
}
|
||||
}
|
||||
if (!arg.readOnly) dialogView.password.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.timeoutWrapper.helperText = "Default timeout: ${TetherTimeoutMonitor.defaultTimeout}ms"
|
||||
if (!arg.readOnly) dialogView.timeout.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) dialogView.band.apply {
|
||||
bandOptions = mutableListOf<BandOption>().apply {
|
||||
if (arg.p2pMode) {
|
||||
@@ -172,6 +179,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
dialogView.ssid.setText(base.ssid)
|
||||
if (!arg.p2pMode) dialogView.security.setSelection(base.securityType)
|
||||
dialogView.password.setText(base.passphrase)
|
||||
dialogView.autoShutdown.isChecked = base.isAutoShutdownEnabled
|
||||
dialogView.timeout.setText(base.shutdownTimeoutMillis.let { if (it == 0L) "" else it.toString() })
|
||||
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
|
||||
dialogView.band.setSelection(if (base.channel in 1..165) {
|
||||
bandOptions.indexOfFirst { it.channel == base.channel }
|
||||
@@ -208,6 +217,15 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
dialogView.passwordWrapper.error = if (passwordValid) null else {
|
||||
requireContext().getString(R.string.credentials_password_too_short)
|
||||
}
|
||||
val timeoutError = dialogView.timeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) null else try {
|
||||
text.toString().toLong()
|
||||
null
|
||||
} catch (e: NumberFormatException) {
|
||||
e.readableMessage
|
||||
}
|
||||
}
|
||||
dialogView.timeoutWrapper.error = timeoutError
|
||||
dialogView.bssidWrapper.error = null
|
||||
val bssidValid = dialogView.bssid.length() == 0 || try {
|
||||
MacAddressCompat.fromString(dialogView.bssid.text.toString())
|
||||
@@ -217,8 +235,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
false
|
||||
}
|
||||
(dialog as? AlertDialog)?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled =
|
||||
ssidLength in 1..32 && passwordValid && bssidValid
|
||||
dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = bssidValid
|
||||
ssidLength in 1..32 && passwordValid && timeoutError == null && bssidValid
|
||||
dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = timeoutError == null && bssidValid
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { }
|
||||
|
||||
@@ -44,7 +44,7 @@ object WifiApManager {
|
||||
*/
|
||||
val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
(getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
|
||||
?: SoftApConfigurationCompat.empty()
|
||||
?: SoftApConfigurationCompat()
|
||||
} else (getSoftApConfiguration(Services.wifi) as SoftApConfiguration).toCompat()
|
||||
fun setConfiguration(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
setWifiApConfiguration(Services.wifi, value.toWifiConfiguration())
|
||||
|
||||
@@ -93,6 +93,31 @@
|
||||
android:maxLength="63"
|
||||
android:imeOptions="flagForceAscii" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<Switch
|
||||
android:id="@+id/auto_shutdown"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dip"
|
||||
style="@style/wifi_item_label"
|
||||
android:text="Turn off hotspot automatically when no devices are connected"/>
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/timeout_wrapper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dip"
|
||||
android:hint="Inactive timeout"
|
||||
app:counterEnabled="true"
|
||||
app:counterMaxLength="19"
|
||||
app:errorEnabled="true"
|
||||
app:suffixText="ms">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/timeout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/wifi_item_edit_content"
|
||||
android:inputType="number"
|
||||
android:maxLength="19" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/band_wrapper"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
Reference in New Issue
Block a user