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") private val dispatcher = newSingleThreadContext("LocalOnlyHotspotService")
override val coroutineContext = dispatcher + Job() override val coroutineContext = dispatcher + Job()
private var routingManager: RoutingManager? = null private var routingManager: RoutingManager? = null
@RequiresApi(28)
private var timeoutMonitor: TetherTimeoutMonitor? = null private var timeoutMonitor: TetherTimeoutMonitor? = null
private var receiverRegistered = false private var receiverRegistered = false
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
@@ -86,8 +85,11 @@ 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( val configuration = binder.configuration!!
this@LocalOnlyHotspotService, reservation::close) if (Build.VERSION.SDK_INT < 30 && configuration.isAutoShutdownEnabled) {
timeoutMonitor = TetherTimeoutMonitor(configuration.shutdownTimeoutMillis,
coroutineContext) { reservation.close() }
}
registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
receiverRegistered = true 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_PASSPHRASE = "service.repeater.passphrase"
private const val KEY_OPERATING_BAND = "service.repeater.band.v2" private const val KEY_OPERATING_BAND = "service.repeater.band.v2"
private const val KEY_OPERATING_CHANNEL = "service.repeater.oc" 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" private const val KEY_DEVICE_ADDRESS = "service.repeater.mac"
/** /**
* Placeholder for bypassing networkName check. * Placeholder for bypassing networkName check.
@@ -83,6 +85,12 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
return if (result > 0) result else 0 return if (result > 0) result else 0
} }
set(value) = app.pref.edit { putString(KEY_OPERATING_CHANNEL, value.toString()) } 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? var deviceAddress: MacAddressCompat?
get() = try { get() = try {
MacAddressCompat(app.pref.getLong(KEY_DEVICE_ADDRESS, MacAddressCompat.ANY_ADDRESS.addr)).run { 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 val p2pManager get() = Services.p2p!!
private var channel: WifiP2pManager.Channel? = null private var channel: WifiP2pManager.Channel? = null
private val binder = Binder() private val binder = Binder()
@RequiresApi(28)
private var timeoutMonitor: TetherTimeoutMonitor? = null private var timeoutMonitor: TetherTimeoutMonitor? = null
private var receiverRegistered = false private var receiverRegistered = false
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
@@ -422,7 +429,9 @@ 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, binder::shutdown) if (isAutoShutdownEnabled) timeoutMonitor = TetherTimeoutMonitor(shutdownTimeoutMillis, coroutineContext) {
binder.shutdown()
}
binder.group = group binder.group = group
if (persistNextGroup) { if (persistNextGroup) {
networkName = group.networkName networkName = group.networkName

View File

@@ -188,7 +188,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
val networkName = RepeaterService.networkName val networkName = RepeaterService.networkName
val passphrase = RepeaterService.passphrase val passphrase = RepeaterService.passphrase
if (networkName != null && passphrase != null) { if (networkName != null && passphrase != null) {
return SoftApConfigurationCompat.empty().apply { return SoftApConfigurationCompat().apply {
ssid = networkName ssid = networkName
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used
this.passphrase = passphrase this.passphrase = passphrase
@@ -199,7 +199,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
} }
} else binder?.let { binder -> } else binder?.let { binder ->
val group = binder.group ?: binder.fetchPersistentGroup().let { binder.group } 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 ssid = group.networkName
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK // is not actually used
band = SoftApConfigurationCompat.BAND_ANY band = SoftApConfigurationCompat.BAND_ANY

View File

@@ -1,27 +1,18 @@
package be.mygod.vpnhotspot.net.monitor package be.mygod.vpnhotspot.net.monitor
import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.database.ContentObserver
import android.os.BatteryManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings import android.provider.Settings
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.os.postDelayed
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.wifi.WifiApManager import be.mygod.vpnhotspot.net.wifi.WifiApManager
import be.mygod.vpnhotspot.util.broadcastReceiver import kotlinx.coroutines.*
import be.mygod.vpnhotspot.util.ensureReceiverUnregistered
import be.mygod.vpnhotspot.util.intentFilter
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.CoroutineContext
@RequiresApi(28) class TetherTimeoutMonitor(private val timeout: Long = 0,
class TetherTimeoutMonitor(private val context: Context, private val onTimeout: () -> Unit, private val context: CoroutineContext = Dispatchers.Main.immediate,
private val handler: Handler = Handler(Looper.getMainLooper())) : private val onTimeout: () -> Unit) : AutoCloseable {
ContentObserver(handler), AutoCloseable {
/** /**
* config_wifi_framework_soft_ap_timeout_delay was introduced in Android 9. * 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. * Minimum limit to use for timeout delay if the value from overlay setting is too small.
*/ */
@RequiresApi(21) private const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
@Deprecated("Use SoftApConfigurationCompat instead") @Deprecated("Use SoftApConfigurationCompat instead")
@get:RequiresApi(28)
@set:RequiresApi(28)
var enabled var enabled
get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1 get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1
set(value) { set(value) {
// TODO: WRITE_SECURE_SETTINGS permission // TODO: WRITE_SECURE_SETTINGS permission
check(Settings.Global.putInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0)) check(Settings.Global.putInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0))
} }
val timeout by lazy { val defaultTimeout: Int get() {
val delay = try { val delay = if (Build.VERSION.SDK_INT >= 28) try {
if (Build.VERSION.SDK_INT < 30) Resources.getSystem().run { if (Build.VERSION.SDK_INT < 30) Resources.getSystem().run {
getInteger(getIdentifier("config_wifi_framework_soft_ap_timeout_delay", "integer", "android")) getInteger(getIdentifier("config_wifi_framework_soft_ap_timeout_delay", "integer", "android"))
} else app.packageManager.getResourcesForApplication(WifiApManager.resolvedActivity.activityInfo } else app.packageManager.getResourcesForApplication(WifiApManager.resolvedActivity.activityInfo
@@ -59,57 +51,27 @@ class TetherTimeoutMonitor(private val context: Context, private val onTimeout:
} catch (e: Resources.NotFoundException) { } catch (e: Resources.NotFoundException) {
Timber.w(e) Timber.w(e)
MIN_SOFT_AP_TIMEOUT_DELAY_MS MIN_SOFT_AP_TIMEOUT_DELAY_MS
} } else MIN_SOFT_AP_TIMEOUT_DELAY_MS
if (Build.VERSION.SDK_INT < 30 && delay < 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") Timber.w("Overriding timeout delay with minimum limit value: $delay < $MIN_SOFT_AP_TIMEOUT_DELAY_MS")
MIN_SOFT_AP_TIMEOUT_DELAY_MS MIN_SOFT_AP_TIMEOUT_DELAY_MS
} else delay } 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 noClient = true
private var timeoutPending = false private var timeoutJob: Job? = null
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() { override fun close() {
context.ensureReceiverUnregistered(receiver) timeoutJob?.cancel()
context.contentResolver.unregisterContentObserver(this) timeoutJob = null
} }
fun onClientsChanged(noClient: Boolean) { fun onClientsChanged(noClient: Boolean) {
this.noClient = noClient this.noClient = noClient
onChange(true) if (!noClient) close() else if (timeoutJob == null) timeoutJob = GlobalScope.launch(context) {
} delay(if (timeout == 0L) defaultTimeout.toLong() else timeout)
onTimeout()
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
}
} }
} }
} }

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.enabled else false,
if (Build.VERSION.SDK_INT >= 28) {
TetherTimeoutMonitor.timeout.toLong()
} else TetherTimeoutMonitor.MIN_SOFT_AP_TIMEOUT_DELAY_MS.toLong(),
underlying = this) underlying = this)
@RequiresApi(30) @RequiresApi(30)
@@ -244,12 +241,6 @@ data class SoftApConfigurationCompat(
getBlockedClientList(this) as List<MacAddress?>, getBlockedClientList(this) as List<MacAddress?>,
getAllowedClientList(this) as List<MacAddress?>, getAllowedClientList(this) as List<MacAddress?>,
this) 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") @Suppress("DEPRECATION")

View File

@@ -25,6 +25,7 @@ import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.RepeaterService import be.mygod.vpnhotspot.RepeaterService
import be.mygod.vpnhotspot.databinding.DialogWifiApBinding import be.mygod.vpnhotspot.databinding.DialogWifiApBinding
import be.mygod.vpnhotspot.net.MacAddressCompat import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
import be.mygod.vpnhotspot.util.QRCodeDialog import be.mygod.vpnhotspot.util.QRCodeDialog
import be.mygod.vpnhotspot.util.readableMessage import be.mygod.vpnhotspot.util.readableMessage
import be.mygod.vpnhotspot.util.showAllowingStateLoss import be.mygod.vpnhotspot.util.showAllowingStateLoss
@@ -109,6 +110,10 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
isHiddenSsid = dialogView.hiddenSsid.isChecked isHiddenSsid = dialogView.hiddenSsid.isChecked
} }
if (full) { 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 val bandOption = dialogView.band.selectedItem as BandOption
band = bandOption.band band = bandOption.band
channel = bandOption.channel channel = bandOption.channel
@@ -141,6 +146,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
} }
} }
if (!arg.readOnly) dialogView.password.addTextChangedListener(this@WifiApDialogFragment) 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 { if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) dialogView.band.apply {
bandOptions = mutableListOf<BandOption>().apply { bandOptions = mutableListOf<BandOption>().apply {
if (arg.p2pMode) { if (arg.p2pMode) {
@@ -172,6 +179,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
dialogView.ssid.setText(base.ssid) dialogView.ssid.setText(base.ssid)
if (!arg.p2pMode) dialogView.security.setSelection(base.securityType) if (!arg.p2pMode) dialogView.security.setSelection(base.securityType)
dialogView.password.setText(base.passphrase) 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) { if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
dialogView.band.setSelection(if (base.channel in 1..165) { dialogView.band.setSelection(if (base.channel in 1..165) {
bandOptions.indexOfFirst { it.channel == base.channel } bandOptions.indexOfFirst { it.channel == base.channel }
@@ -208,6 +217,15 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
dialogView.passwordWrapper.error = if (passwordValid) null else { dialogView.passwordWrapper.error = if (passwordValid) null else {
requireContext().getString(R.string.credentials_password_too_short) 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 dialogView.bssidWrapper.error = null
val bssidValid = dialogView.bssid.length() == 0 || try { val bssidValid = dialogView.bssid.length() == 0 || try {
MacAddressCompat.fromString(dialogView.bssid.text.toString()) MacAddressCompat.fromString(dialogView.bssid.text.toString())
@@ -217,8 +235,8 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
false false
} }
(dialog as? AlertDialog)?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = (dialog as? AlertDialog)?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled =
ssidLength in 1..32 && passwordValid && bssidValid ssidLength in 1..32 && passwordValid && timeoutError == null && bssidValid
dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = bssidValid dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = timeoutError == null && bssidValid
} }
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } 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") { val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
(getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat() (getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
?: SoftApConfigurationCompat.empty() ?: SoftApConfigurationCompat()
} else (getSoftApConfiguration(Services.wifi) as SoftApConfiguration).toCompat() } else (getSoftApConfiguration(Services.wifi) as SoftApConfiguration).toCompat()
fun setConfiguration(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { fun setConfiguration(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
setWifiApConfiguration(Services.wifi, value.toWifiConfiguration()) setWifiApConfiguration(Services.wifi, value.toWifiConfiguration())

View File

@@ -93,6 +93,31 @@
android:maxLength="63" android:maxLength="63"
android:imeOptions="flagForceAscii" /> android:imeOptions="flagForceAscii" />
</com.google.android.material.textfield.TextInputLayout> </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 <LinearLayout
android:id="@+id/band_wrapper" android:id="@+id/band_wrapper"
android:layout_width="match_parent" android:layout_width="match_parent"