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:
Mygod
2020-07-02 09:06:46 +08:00
parent 4c5265f0c2
commit 0b02e7565e
8 changed files with 82 additions and 75 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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")

View File

@@ -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) { }

View File

@@ -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())

View File

@@ -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"