Merge branch 'master' into temp-hotspot-use-system

This commit is contained in:
Mygod
2023-03-02 23:19:51 -05:00
148 changed files with 3939 additions and 3409 deletions

View File

@@ -1,5 +1,6 @@
package be.mygod.vpnhotspot.net
import android.net.MacAddress
import android.os.Build
import android.system.ErrnoException
import android.system.Os
@@ -15,7 +16,7 @@ import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddressCompat, val state: State) {
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddress, val state: State) {
enum class State {
INCOMPLETE, VALID, FAILED, DELETING
}
@@ -27,8 +28,8 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
* https://people.cs.clemson.edu/~westall/853/notes/arpstate.pdf
* Assumptions: IP addr (key) always present and RTM_GETNEIGH is never used
*/
private val parser = "^(Deleted )?([^ ]+) dev ([^ ]+) (lladdr ([^ ]*))?.*?( ([INCOMPLET,RAHBSDYF]+))?\$"
.toRegex()
private val parser = ("^(Deleted )?(?:([^ ]+) )?dev ([^ ]+) (?:lladdr ([^ ]*))?.*?" +
"(?: ([INCOMPLET,RAHBSDYF]+))?\$").toRegex()
/**
* Fallback format will be used if if_indextoname returns null, which some stupid devices do.
*
@@ -49,14 +50,15 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
suspend fun parse(line: String, fullMode: Boolean): List<IpNeighbour> {
return if (line.isBlank()) emptyList() else try {
val match = parser.matchEntire(line)!!
val ip = parseNumericAddress(match.groupValues[2]) // by regex, ip is non-empty
val devs = substituteDev(match.groupValues[3]) // by regex, dev is non-empty as well
val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else when (match.groupValues[7]) {
if (match.groups[2] == null) return emptyList()
val ip = parseNumericAddress(match.groupValues[2])
val devs = substituteDev(match.groupValues[3]) // by regex, dev is non-empty
val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else when (match.groupValues[5]) {
"", "INCOMPLETE" -> State.INCOMPLETE
"REACHABLE", "DELAY", "STALE", "PROBE", "PERMANENT" -> State.VALID
"FAILED" -> State.FAILED
"NOARP" -> return emptyList() // skip
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}")
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[5]}")
}
var lladdr = MacAddressCompat.ALL_ZEROS_ADDRESS
if (!fullMode && state != State.VALID) {
@@ -64,7 +66,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
return devs.map { IpNeighbour(ip, it, lladdr, State.DELETING) }
}
if (match.groups[4] != null) try {
lladdr = MacAddressCompat.fromString(match.groupValues[5])
lladdr = MacAddress.fromString(match.groupValues[4])
} catch (e: IllegalArgumentException) {
if (state != State.INCOMPLETE && state != State.DELETING) {
Timber.w(IOException("Failed to find MAC address for $line", e))
@@ -78,7 +80,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
val list = arp()
.asSequence()
.filter { parseNumericAddress(it[ARP_IP_ADDRESS]) == ip && it[ARP_DEVICE] in devs }
.map { MacAddressCompat.fromString(it[ARP_HW_ADDRESS]) }
.map { MacAddress.fromString(it[ARP_HW_ADDRESS]) }
.filter { it != MacAddressCompat.ALL_ZEROS_ADDRESS }
.distinct()
.toList()
@@ -137,5 +139,4 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
data class IpDev(val ip: InetAddress, val dev: String) {
override fun toString() = "$ip%$dev"
}
@Suppress("FunctionName")
fun IpDev(neighbour: IpNeighbour) = IpDev(neighbour.ip, neighbour.dev)

View File

@@ -1,97 +1,34 @@
package be.mygod.vpnhotspot.net
import android.net.MacAddress
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Compat support class for [MacAddress].
* This used to be a compat support class for [MacAddress].
* Now it is just a convenient class for backwards compatibility.
*/
@JvmInline
value class MacAddressCompat(val addr: Long) {
companion object {
private const val ETHER_ADDR_LEN = 6
/**
* The MacAddress zero MAC address.
*
* Not publicly exposed or treated specially since the OUI 00:00:00 is registered.
* @hide
*/
val ALL_ZEROS_ADDRESS = MacAddressCompat(0)
val ANY_ADDRESS = MacAddressCompat(2)
val ALL_ZEROS_ADDRESS = MacAddress.fromBytes(byteArrayOf(0, 0, 0, 0, 0, 0))
val ANY_ADDRESS = MacAddress.fromBytes(byteArrayOf(2, 0, 0, 0, 0, 0))
/**
* Creates a MacAddress from the given byte array representation.
* A valid byte array representation for a MacAddress is a non-null array of length 6.
*
* @param addr a byte array representation of a MAC address.
* @return the MacAddress corresponding to the given byte array representation.
* @throws IllegalArgumentException if the given byte array is not a valid representation.
*/
fun fromBytes(addr: ByteArray) = ByteBuffer.allocate(Long.SIZE_BYTES).run {
fun MacAddress.toLong() = ByteBuffer.allocate(Long.SIZE_BYTES).apply {
order(ByteOrder.LITTLE_ENDIAN)
put(when (addr.size) {
ETHER_ADDR_LEN -> addr
8 -> {
require(addr.take(2).all { it == 0.toByte() }) {
"Unrecognized padding " + addr.joinToString(":") { "%02x".format(it) }
}
addr.drop(2).toByteArray()
}
else -> throw IllegalArgumentException(addr.joinToString(":") { "%02x".format(it) } +
" was not a valid MAC address")
})
put(toByteArray())
rewind()
MacAddressCompat(long)
}
/**
* Creates a MacAddress from the given String representation. A valid String representation
* for a MacAddress is a series of 6 values in the range [0,ff] printed in hexadecimal
* and joined by ':' characters.
*
* @param addr a String representation of a MAC address.
* @return the MacAddress corresponding to the given String representation.
* @throws IllegalArgumentException if the given String is not a valid representation.
*/
fun fromString(addr: String) = ByteBuffer.allocate(Long.SIZE_BYTES).run {
order(ByteOrder.LITTLE_ENDIAN)
var start = 0
var i = 0
while (position() < ETHER_ADDR_LEN && start < addr.length) {
val end = i
if (addr.getOrElse(i) { ':' } == ':') ++i else if (i < start + 2) {
++i
continue
}
put(if (start == end) 0 else try {
Integer.parseInt(addr.substring(start, end), 16).toByte()
} catch (e: NumberFormatException) {
throw IllegalArgumentException(e)
})
start = i
}
require(position() == ETHER_ADDR_LEN) { "MAC address too short" }
rewind()
MacAddressCompat(long)
}
@RequiresApi(28)
fun MacAddress.toCompat() = fromBytes(toByteArray())
}.long
}
fun validate() = require(addr and ((1L shl 48) - 1).inv() == 0L)
fun toList() = ByteBuffer.allocate(8).run {
fun toPlatform() = MacAddress.fromBytes(ByteBuffer.allocate(8).run {
order(ByteOrder.LITTLE_ENDIAN)
putLong(addr)
array().take(6)
}
@RequiresApi(28)
fun toPlatform() = MacAddress.fromBytes(toList().toByteArray())
override fun toString() = toList().joinToString(":") { "%02x".format(it) }
fun toOui() = toList().joinToString("") { "%02x".format(it) }.substring(0, 9)
}.toByteArray())
}

View File

@@ -1,11 +1,9 @@
package be.mygod.vpnhotspot.net
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.net.LinkProperties
import android.net.MacAddress
import android.net.RouteInfo
import android.os.Build
import androidx.annotation.RequiresApi
import android.system.Os
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
@@ -15,7 +13,9 @@ import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.RoutingCommands
import be.mygod.vpnhotspot.util.*
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.allInterfaceNames
import be.mygod.vpnhotspot.util.allRoutes
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.CancellationException
import timber.log.Timber
@@ -125,7 +125,6 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
*
* Source: https://android.googlesource.com/platform/system/netd/+/3b47c793ff7ade843b1d85a9be8461c3b4dc693e
*/
@RequiresApi(28)
Netd,
}
@@ -151,35 +150,24 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
private val upstreams = HashSet<String>()
private class InterfaceGoneException(upstream: String) : IOException("Interface $upstream not found")
private open inner class Upstream(val priority: Int) : UpstreamMonitor.Callback {
/**
* The only case when upstream is null is on API 23- and we are using system default rules.
*/
inner class Subrouting(priority: Int, val upstream: String) {
val ifindex = if (upstream.isEmpty()) 0 else if_nametoindex(upstream).also {
val ifindex = Os.if_nametoindex(upstream).also {
if (it <= 0) throw InterfaceGoneException(upstream)
}
val transaction = RootSession.beginTransaction().safeguard {
if (upstream.isEmpty()) {
ipRule("goto $RULE_PRIORITY_TETHERING", priority) // skip unreachable rule
} else ipRuleLookup(ifindex, priority)
@TargetApi(28) when (masqueradeMode) {
ipRuleLookup(ifindex, priority)
when (masqueradeMode) {
MasqueradeMode.None -> { } // nothing to be done here
MasqueradeMode.Simple -> {
// note: specifying -i wouldn't work for POSTROUTING
iptablesAdd(if (upstream.isEmpty()) {
"vpnhotspot_masquerade -s $hostSubnet -j MASQUERADE"
} else "vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat")
}
MasqueradeMode.Netd -> {
check(upstream.isNotEmpty()) // fallback is only needed for repeater on API 23 < 28
/**
* 0 means that there are no interface addresses coming after, which is unused anyway.
*
* https://android.googlesource.com/platform/frameworks/base/+/android-5.0.0_r1/services/core/java/com/android/server/NetworkManagementService.java#1251
* https://android.googlesource.com/platform/system/netd/+/android-5.0.0_r1/server/CommandListener.cpp#638
*/
ndc("Nat", "ndc nat enable $downstream $upstream 0")
}
// note: specifying -i wouldn't work for POSTROUTING
MasqueradeMode.Simple -> iptablesAdd(
"vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat")
/**
* 0 means that there are no interface addresses coming after, which is unused anyway.
*
* https://android.googlesource.com/platform/frameworks/base/+/android-5.0.0_r1/services/core/java/com/android/server/NetworkManagementService.java#1251
* https://android.googlesource.com/platform/system/netd/+/android-5.0.0_r1/server/CommandListener.cpp#638
*/
MasqueradeMode.Netd -> ndc("Nat", "ndc nat enable $downstream $upstream 0")
}
}
}
@@ -225,16 +213,10 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
updateDnsRoute()
}
}
private val fallbackUpstream = object : Upstream(RULE_PRIORITY_UPSTREAM_FALLBACK) {
@SuppressLint("NewApi")
override fun onFallback() = onAvailable(LinkProperties().apply {
interfaceName = ""
setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
})
}
private val fallbackUpstream = Upstream(RULE_PRIORITY_UPSTREAM_FALLBACK)
private val upstream = Upstream(RULE_PRIORITY_UPSTREAM)
private inner class Client(private val ip: Inet4Address, mac: MacAddressCompat) : AutoCloseable {
private inner class Client(private val ip: Inet4Address, mac: MacAddress) : AutoCloseable {
private val transaction = RootSession.beginTransaction().safeguard {
val address = ip.hostAddress
iptablesInsert("vpnhotspot_acl -i $downstream -s $address -j ACCEPT")
@@ -287,9 +269,9 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
* but may be broken when system tethering shutdown before local-only interfaces.
*/
fun ipForward() {
if (Build.VERSION.SDK_INT >= 23) try {
try {
transaction.ndc("ipfwd", "ndc ipfwd enable vpnhotspot_$downstream",
"ndc ipfwd disable vpnhotspot_$downstream")
"ndc ipfwd disable vpnhotspot_$downstream")
return
} catch (e: RoutingCommands.UnexpectedOutputException) {
Timber.w(IOException("ndc ipfwd enable failure", e))

View File

@@ -1,10 +1,8 @@
package be.mygod.vpnhotspot.net
import android.os.Build
import android.provider.Settings
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.root.SettingsGlobalPut
import timber.log.Timber
/**
* It's hard to change tethering rules with Tethering hardware acceleration enabled for now.
@@ -15,19 +13,6 @@ import timber.log.Timber
* https://android.googlesource.com/platform/hardware/qcom/data/ipacfg-mgr/+/master/msm8998/ipacm/src/IPACM_OffloadManager.cpp
*/
object TetherOffloadManager {
val supported by lazy {
Build.VERSION.SDK_INT >= 27 || try {
Settings.Global::class.java.getDeclaredField("TETHER_OFFLOAD_DISABLED").get(null).let {
require(it == TETHER_OFFLOAD_DISABLED) { "Unknown field $it" }
}
true
} catch (_: NoSuchFieldException) {
false
} catch (e: Exception) {
Timber.w(e)
false
}
}
private const val TETHER_OFFLOAD_DISABLED = "tether_offload_disabled"
val enabled get() = Settings.Global.getInt(app.contentResolver, TETHER_OFFLOAD_DISABLED, 0) == 0
suspend fun setEnabled(value: Boolean) = SettingsGlobalPut.int(TETHER_OFFLOAD_DISABLED, if (value) 0 else 1)

View File

@@ -28,6 +28,8 @@ enum class TetherType(@DrawableRes val icon: Int) {
else -> false
}
fun isA(other: TetherType) = this == other || other == USB && this == NCM
companion object : TetheringManager.TetheringEventCallback {
private lateinit var usbRegexs: List<Pattern>
private lateinit var wifiRegexs: List<Pattern>
@@ -58,6 +60,9 @@ enum class TetherType(@DrawableRes val icon: Int) {
private fun updateRegexs() = synchronized(this) {
if (!requiresUpdate) return@synchronized
requiresUpdate = false
usbRegexs = emptyList()
wifiRegexs = emptyList()
bluetoothRegexs = emptyList()
TetheringManager.registerTetheringEventCallback(null, this)
val info = TetheringManager.resolvedService.serviceInfo
val tethering = "com.android.networkstack.tethering" to
@@ -71,9 +76,9 @@ enum class TetherType(@DrawableRes val icon: Int) {
}
@RequiresApi(30)
override fun onTetherableInterfaceRegexpsChanged(args: Array<out Any?>?) = synchronized(this) {
override fun onTetherableInterfaceRegexpsChanged(reg: Any?) = synchronized(this) {
if (requiresUpdate) return@synchronized
Timber.i("onTetherableInterfaceRegexpsChanged: ${args?.contentDeepToString()}")
Timber.i("onTetherableInterfaceRegexpsChanged: $reg")
TetheringManager.unregisterTetheringEventCallback(this)
requiresUpdate = true
listener()
@@ -104,13 +109,20 @@ enum class TetherType(@DrawableRes val icon: Int) {
*
* Based on: https://android.googlesource.com/platform/frameworks/base/+/5d36f01/packages/Tethering/src/com/android/networkstack/tethering/Tethering.java#479
*/
fun ofInterface(iface: String?, p2pDev: String? = null) = synchronized(this) { ofInterfaceImpl(iface, p2pDev) }
private tailrec fun ofInterfaceImpl(iface: String?, p2pDev: String?): TetherType = when {
iface == null -> NONE
iface == p2pDev -> WIFI_P2P
fun ofInterface(iface: String?, p2pDev: String? = null) = when (iface) {
null -> NONE
p2pDev -> WIFI_P2P
else -> try {
synchronized(this) { ofInterfaceImpl(iface) }
} catch (e: RuntimeException) {
Timber.w(e)
NONE
}
}
private tailrec fun ofInterfaceImpl(iface: String): TetherType = when {
requiresUpdate -> {
if (Build.VERSION.SDK_INT >= 30) updateRegexs() else error("unexpected requiresUpdate")
ofInterfaceImpl(iface, p2pDev)
ofInterfaceImpl(iface)
}
wifiRegexs.any { it.matcher(iface).matches() } -> WIFI
wigigRegexs.any { it.matcher(iface).matches() } -> WIGIG

View File

@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
import android.os.DeadObjectException
import android.os.Handler
import androidx.annotation.RequiresApi
import androidx.core.os.ExecutorCompat
@@ -66,7 +67,11 @@ object TetheringManager {
}
private object InPlaceExecutor : Executor {
override fun execute(command: Runnable) = command.run()
override fun execute(command: Runnable) = try {
command.run()
} catch (e: Exception) {
Timber.w(e) // prevent Binder stub swallowing the exception
}
}
/**
@@ -89,9 +94,7 @@ object TetheringManager {
* https://android.googlesource.com/platform/frameworks/base.git/+/2a091d7aa0c174986387e5d56bf97a87fe075bdb%5E%21/services/java/com/android/server/connectivity/Tethering.java
*/
const val ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
@RequiresApi(26)
private const val EXTRA_ACTIVE_LOCAL_ONLY_LEGACY = "localOnlyArray"
private const val EXTRA_ACTIVE_TETHER_LEGACY = "activeArray"
/**
* gives a String[] listing all the interfaces currently in local-only
* mode (ie, has DHCPv4+IPv6-ULA support and no packet forwarding)
@@ -102,7 +105,6 @@ object TetheringManager {
* gives a String[] listing all the interfaces currently tethered
* (ie, has DHCPv4 support and packets potentially forwarded/NATed)
*/
@RequiresApi(26)
private const val EXTRA_ACTIVE_TETHER = "tetherArray"
/**
* gives a String[] listing all the interfaces we tried to tether and
@@ -126,7 +128,6 @@ object TetheringManager {
* Wifi tethering type.
* @see [startTethering].
*/
@RequiresApi(24)
const val TETHERING_WIFI = 0
/**
* USB tethering type.
@@ -134,48 +135,33 @@ object TetheringManager {
* Requires MANAGE_USB permission, unfortunately.
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/7ca5d3a/services/usb/java/com/android/server/usb/UsbService.java#389
* @see [startTethering].
* @see startTethering
*/
@RequiresApi(24)
const val TETHERING_USB = 1
/**
* Bluetooth tethering type.
*
* Requires BLUETOOTH permission.
* @see [startTethering].
* @see startTethering
*/
@RequiresApi(24)
const val TETHERING_BLUETOOTH = 2
/**
* Ncm local tethering type.
*
* @see [startTethering]
*/
@RequiresApi(30)
const val TETHERING_NCM = 4
/**
* Ethernet tethering type.
*
* Requires MANAGE_USB permission, also.
* @see [startTethering]
* @see startTethering
*/
@RequiresApi(30)
const val TETHERING_ETHERNET = 5
/**
* WIGIG tethering type. Use a separate type to prevent
* conflicts with TETHERING_WIFI
* This type is only used internally by the tethering module
* @hide
*/
@RequiresApi(30)
const val TETHERING_WIGIG = 6
@RequiresApi(31) // TETHERING_WIFI_P2P
private val expectedTypes = setOf(TETHERING_WIFI, TETHERING_USB, TETHERING_BLUETOOTH, 3, TETHERING_ETHERNET)
@get:RequiresApi(30)
private val clazz by lazy { Class.forName("android.net.TetheringManager") }
@get:RequiresApi(30)
private val instance by lazy @TargetApi(30) {
@SuppressLint("WrongConstant") // hidden services are not included in constants as of R preview 4
val service = Services.context.getSystemService(TETHERING_SERVICE)
val service = Services.context.getSystemService(TETHERING_SERVICE)!!
service
}
@@ -188,20 +174,17 @@ object TetheringManager {
}
}.first()
@get:RequiresApi(24)
private val classOnStartTetheringCallback by lazy {
Class.forName("android.net.ConnectivityManager\$OnStartTetheringCallback")
}
@get:RequiresApi(24)
private val startTetheringLegacy by lazy {
ConnectivityManager::class.java.getDeclaredMethod("startTethering",
Int::class.java, Boolean::class.java, classOnStartTetheringCallback, Handler::class.java)
}
@get:RequiresApi(24)
private val stopTetheringLegacy by lazy {
ConnectivityManager::class.java.getDeclaredMethod("stopTethering", Int::class.java)
}
private val getLastTetherError by lazy {
private val getLastTetherError by lazy @SuppressLint("SoonBlockedPrivateApi") {
ConnectivityManager::class.java.getDeclaredMethod("getLastTetherError", String::class.java)
}
@@ -210,50 +193,50 @@ object TetheringManager {
Class.forName("android.net.TetheringManager\$TetheringRequest\$Builder")
}
@get:RequiresApi(30)
private val newTetheringRequestBuilder by lazy { classTetheringRequestBuilder.getConstructor(Int::class.java) }
private val newTetheringRequestBuilder by lazy @TargetApi(30) {
classTetheringRequestBuilder.getConstructor(Int::class.java)
}
// @get:RequiresApi(30)
// private val setStaticIpv4Addresses by lazy {
// classTetheringRequestBuilder.getDeclaredMethod("setStaticIpv4Addresses",
// LinkAddress::class.java, LinkAddress::class.java)
// }
@get:RequiresApi(30)
private val setExemptFromEntitlementCheck by lazy {
private val setExemptFromEntitlementCheck by lazy @TargetApi(30) {
classTetheringRequestBuilder.getDeclaredMethod("setExemptFromEntitlementCheck", Boolean::class.java)
}
@get:RequiresApi(30)
private val setShouldShowEntitlementUi by lazy {
private val setShouldShowEntitlementUi by lazy @TargetApi(30) {
classTetheringRequestBuilder.getDeclaredMethod("setShouldShowEntitlementUi", Boolean::class.java)
}
@get:RequiresApi(30)
private val build by lazy { classTetheringRequestBuilder.getDeclaredMethod("build") }
private val build by lazy @TargetApi(30) { classTetheringRequestBuilder.getDeclaredMethod("build") }
@get:RequiresApi(30)
private val interfaceStartTetheringCallback by lazy {
Class.forName("android.net.TetheringManager\$StartTetheringCallback")
}
@get:RequiresApi(30)
private val startTethering by lazy {
private val startTethering by lazy @TargetApi(30) {
clazz.getDeclaredMethod("startTethering", Class.forName("android.net.TetheringManager\$TetheringRequest"),
Executor::class.java, interfaceStartTetheringCallback)
}
@get:RequiresApi(30)
private val stopTethering by lazy { clazz.getDeclaredMethod("stopTethering", Int::class.java) }
private val stopTethering by lazy @TargetApi(30) { clazz.getDeclaredMethod("stopTethering", Int::class.java) }
@Deprecated("Legacy API")
@RequiresApi(24)
fun startTetheringLegacy(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
val reference = WeakReference(callback)
val proxy = ProxyBuilder.forClass(classOnStartTetheringCallback).apply {
dexCache(cacheDir)
handler { proxy, method, args ->
if (args.isNotEmpty()) Timber.w("Unexpected args for ${method.name}: $args")
@Suppress("NAME_SHADOWING") val callback = reference.get()
when (method.name) {
"onTetheringStarted" -> callback?.onTetheringStarted()
"onTetheringFailed" -> callback?.onTetheringFailed()
else -> ProxyBuilder.callSuper(proxy, method, args)
if (args.isEmpty()) when (method.name) {
"onTetheringStarted" -> return@handler callback?.onTetheringStarted()
"onTetheringFailed" -> return@handler callback?.onTetheringFailed()
}
ProxyBuilder.callSuper(proxy, method, args)
}
}.build()
startTetheringLegacy(Services.connectivity, type, showProvisioningUi, proxy, handler)
@@ -275,13 +258,9 @@ object TetheringManager {
arrayOf(interfaceStartTetheringCallback), object : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
@Suppress("NAME_SHADOWING") val callback = reference.get()
return when (val name = method.name) {
"onTetheringStarted" -> {
if (!args.isNullOrEmpty()) Timber.w("Unexpected args for $name: $args")
callback?.onTetheringStarted()
}
"onTetheringFailed" -> {
if (args?.size != 1) Timber.w("Unexpected args for $name: $args")
return when {
method.matches("onTetheringStarted") -> callback?.onTetheringStarted()
method.matches("onTetheringFailed", Integer.TYPE) -> {
callback?.onTetheringFailed(args?.get(0) as Int)
}
else -> callSuper(interfaceStartTetheringCallback, proxy, method, args)
@@ -312,7 +291,6 @@ object TetheringManager {
* configures tethering with the preferred local IPv4 link address to use.
* *@see setStaticIpv4Addresses
*/
@RequiresApi(24)
fun startTethering(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
if (Build.VERSION.SDK_INT >= 30) try {
@@ -384,12 +362,10 @@ object TetheringManager {
* {@link ConnectivityManager.TETHERING_USB}, or
* {@link ConnectivityManager.TETHERING_BLUETOOTH}.
*/
@RequiresApi(24)
fun stopTethering(type: Int) {
if (Build.VERSION.SDK_INT >= 30) stopTethering(instance, type)
else stopTetheringLegacy(Services.connectivity, type)
}
@RequiresApi(24)
fun stopTethering(type: Int, callback: (Exception) -> Unit) {
try {
stopTethering(type)
@@ -423,6 +399,23 @@ object TetheringManager {
*/
fun onTetheringSupported(supported: Boolean) {}
/**
* Called when tethering supported status changed.
*
* This will be called immediately after the callback is registered, and may be called
* multiple times later upon changes.
*
* Tethering may be disabled via system properties, device configuration, or device
* policy restrictions.
*
* @param supportedTypes a set of @TetheringType which is supported.
*/
@TargetApi(31)
fun onSupportedTetheringTypes(supportedTypes: Set<Int?>) {
if ((supportedTypes - expectedTypes).isNotEmpty()) Timber.w(Exception(
"Unexpected supported tethering types: ${supportedTypes.joinToString()}"))
}
/**
* Called when tethering upstream changed.
*
@@ -445,7 +438,7 @@ object TetheringManager {
* *@param reg The new regular expressions.
* @hide
*/
fun onTetherableInterfaceRegexpsChanged(args: Array<out Any?>?) {}
fun onTetherableInterfaceRegexpsChanged(reg: Any?) {}
/**
* Called when there was a change in the list of tetherable interfaces. Tetherable
@@ -507,11 +500,11 @@ object TetheringManager {
Class.forName("android.net.TetheringManager\$TetheringEventCallback")
}
@get:RequiresApi(30)
private val registerTetheringEventCallback by lazy {
private val registerTetheringEventCallback by lazy @TargetApi(30) {
clazz.getDeclaredMethod("registerTetheringEventCallback", Executor::class.java, interfaceTetheringEventCallback)
}
@get:RequiresApi(30)
private val unregisterTetheringEventCallback by lazy {
private val unregisterTetheringEventCallback by lazy @TargetApi(30) {
clazz.getDeclaredMethod("unregisterTetheringEventCallback", interfaceTetheringEventCallback)
}
@@ -541,40 +534,38 @@ object TetheringManager {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
@Suppress("NAME_SHADOWING")
val callback = reference.get()
val noArgs = args?.size ?: 0
return when (val name = method.name) {
"onTetheringSupported" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
return when {
method.matches("onTetheringSupported", Boolean::class.java) -> {
callback?.onTetheringSupported(args!![0] as Boolean)
}
"onUpstreamChanged" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
method.matches1<java.util.Set<*>>("onSupportedTetheringTypes") -> {
@Suppress("UNCHECKED_CAST")
callback?.onSupportedTetheringTypes(args!![0] as Set<Int?>)
}
method.matches1<Network>("onUpstreamChanged") -> {
callback?.onUpstreamChanged(args!![0] as Network?)
}
"onTetherableInterfaceRegexpsChanged" -> {
if (regexpsSent) callback?.onTetherableInterfaceRegexpsChanged(args)
method.name == "onTetherableInterfaceRegexpsChanged" &&
method.parameters.singleOrNull()?.type?.name ==
"android.net.TetheringManager\$TetheringInterfaceRegexps" -> {
if (regexpsSent) callback?.onTetherableInterfaceRegexpsChanged(args!!.single())
regexpsSent = true
}
"onTetherableInterfacesChanged" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
method.matches1<java.util.List<*>>("onTetherableInterfacesChanged") -> {
@Suppress("UNCHECKED_CAST")
callback?.onTetherableInterfacesChanged(args!![0] as List<String?>)
}
"onTetheredInterfacesChanged" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
method.matches1<java.util.List<*>>("onTetheredInterfacesChanged") -> {
@Suppress("UNCHECKED_CAST")
callback?.onTetheredInterfacesChanged(args!![0] as List<String?>)
}
"onError" -> {
if (noArgs != 2) Timber.w("Unexpected args for $name: $args")
method.matches("onError", String::class.java, Integer.TYPE) -> {
callback?.onError(args!![0] as String, args[1] as Int)
}
"onClientsChanged" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
method.matches1<java.util.Collection<*>>("onClientsChanged") -> {
callback?.onClientsChanged(args!![0] as Collection<*>)
}
"onOffloadStatusChanged" -> {
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
method.matches("onOffloadStatusChanged", Integer.TYPE) -> {
callback?.onOffloadStatusChanged(args!![0] as Int)
}
else -> callSuper(interfaceTetheringEventCallback, proxy, method, args)
@@ -596,7 +587,11 @@ object TetheringManager {
@RequiresApi(30)
fun unregisterTetheringEventCallback(callback: TetheringEventCallback) {
val proxy = synchronized(callbackMap) { callbackMap.remove(callback) } ?: return
unregisterTetheringEventCallback(instance, proxy)
try {
unregisterTetheringEventCallback(instance, proxy)
} catch (e: InvocationTargetException) {
if (!e.targetException.let { it is IllegalStateException && it.cause is DeadObjectException }) throw e
}
}
/**
@@ -636,14 +631,11 @@ object TetheringManager {
"TETHER_ERROR_UNSUPPORTED", "TETHER_ERROR_UNAVAIL_IFACE", "TETHER_ERROR_MASTER_ERROR",
"TETHER_ERROR_TETHER_IFACE_ERROR", "TETHER_ERROR_UNTETHER_IFACE_ERROR", "TETHER_ERROR_ENABLE_NAT_ERROR",
"TETHER_ERROR_DISABLE_NAT_ERROR", "TETHER_ERROR_IFACE_CFG_ERROR", "TETHER_ERROR_PROVISION_FAILED",
"TETHER_ERROR_DHCPSERVER_ERROR", "TETHER_ERROR_ENTITLEMENT_UNKNOWN") { clazz }
"TETHER_ERROR_DHCPSERVER_ERROR", "TETHER_ERROR_ENTITLEMENT_UNKNOWN") @TargetApi(30) { clazz }
@RequiresApi(30)
const val TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14
val Intent.tetheredIfaces get() = getStringArrayListExtra(
if (Build.VERSION.SDK_INT >= 26) EXTRA_ACTIVE_TETHER else EXTRA_ACTIVE_TETHER_LEGACY)
val Intent.localOnlyTetheredIfaces get() = if (Build.VERSION.SDK_INT >= 26) {
getStringArrayListExtra(
if (Build.VERSION.SDK_INT >= 30) EXTRA_ACTIVE_LOCAL_ONLY else EXTRA_ACTIVE_LOCAL_ONLY_LEGACY)
} else emptyList<String>()
val Intent.tetheredIfaces get() = getStringArrayListExtra(EXTRA_ACTIVE_TETHER)
val Intent.localOnlyTetheredIfaces get() = getStringArrayListExtra(
if (Build.VERSION.SDK_INT >= 30) EXTRA_ACTIVE_LOCAL_ONLY else EXTRA_ACTIVE_LOCAL_ONLY_LEGACY)
}

View File

@@ -1,14 +1,12 @@
package be.mygod.vpnhotspot.net.monitor
import android.annotation.TargetApi
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Handler
import android.os.Looper
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -20,10 +18,10 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
* Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
*/
private val networkRequest = networkRequestBuilder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
private val networkRequest = globalNetworkRequestBuilder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = Services.connectivity.getLinkProperties(network)
@@ -53,23 +51,10 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
callback.onAvailable(currentLinkProperties)
}
} else {
when (Build.VERSION.SDK_INT) {
in 31..Int.MAX_VALUE -> @TargetApi(31) {
Services.connectivity.registerBestMatchingNetworkCallback(networkRequest, networkCallback,
Handler(Looper.getMainLooper()))
}
in 24..27 -> @TargetApi(24) {
Services.connectivity.registerDefaultNetworkCallback(networkCallback)
}
else -> try {
Services.connectivity.requestNetwork(networkRequest, networkCallback)
} catch (e: SecurityException) {
// SecurityException would be thrown in requestNetwork on Android 6.0 thanks to Google's stupid bug
if (Build.VERSION.SDK_INT != 23) throw e
GlobalScope.launch { callback.onFallback() }
return
}
}
if (Build.VERSION.SDK_INT >= 31) {
Services.connectivity.registerBestMatchingNetworkCallback(networkRequest, networkCallback,
Services.mainHandler)
} else Services.connectivity.requestNetwork(networkRequest, networkCallback, Services.mainHandler)
registered = true
}
}

View File

@@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkCapabilities
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.allInterfaceNames
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -18,7 +19,7 @@ class InterfaceMonitor(private val ifaceRegex: String) : UpstreamMonitor() {
Timber.d(e);
{ it == ifaceRegex }
}
private val request = networkRequestBuilder().apply {
private val request = globalNetworkRequestBuilder().apply {
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
@@ -76,7 +77,7 @@ class InterfaceMonitor(private val ifaceRegex: String) : UpstreamMonitor() {
callback.onAvailable(currentLinkProperties)
}
} else {
Services.connectivity.registerNetworkCallback(request, networkCallback)
Services.registerNetworkCallback(request, networkCallback)
registered = true
}
}

View File

@@ -5,7 +5,6 @@ import androidx.core.content.edit
import be.mygod.librootkotlinx.RootServer
import be.mygod.librootkotlinx.isEBADF
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.BuildConfig
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.root.ProcessData
@@ -25,12 +24,12 @@ abstract class IpMonitor {
companion object {
const val KEY = "service.ipMonitor"
// https://android.googlesource.com/platform/external/iproute2/+/7f7a711/lib/libnetlink.c#493
private val errorMatcher = ("(^Cannot bind netlink socket: |" +
private val errorMatcher = ("(?:^Cannot (?:bind netlink socket|send dump request): |^request send failed: |" +
"Dump (was interrupted and may be inconsistent.|terminated)$)").toRegex()
var currentMode: Mode
get() {
val isLegacy = Build.VERSION.SDK_INT < 30 || BuildConfig.TARGET_SDK < 30
val defaultMode = if (isLegacy) @Suppress("DEPRECATION") {
// Completely restricted on Android 13: https://github.com/termux/termux-app/issues/2993#issuecomment-1250312777
val defaultMode = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
Mode.Poll
} else Mode.MonitorRoot
return Mode.valueOf(app.pref.getString(KEY, defaultMode.toString()) ?: "")
@@ -114,8 +113,8 @@ abstract class IpMonitor {
try {
RootManager.use { server ->
// while we only need to use this server once, we need to also keep the server alive
handleChannel(server.create(ProcessListener(errorMatcher, Routing.IP, "monitor", monitoredObject),
this))
handleChannel(server.create(ProcessListener(errorMatcher,
Routing.IP, "monitor", monitoredObject), this))
}
} catch (_: CancellationException) {
} catch (e: Exception) {
@@ -152,7 +151,7 @@ abstract class IpMonitor {
fun flushAsync() = GlobalScope.launch(Dispatchers.IO) { flush() }
private suspend fun work(server: RootServer?): RootServer? {
if (currentMode != Mode.PollRoot) try {
if (currentMode != Mode.PollRoot && currentMode != Mode.MonitorRoot) try {
poll()
return server
} catch (e: IOException) {

View File

@@ -33,30 +33,41 @@ class TetherTimeoutMonitor(private val timeout: Long = 0,
private const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
@Deprecated("Use SoftApConfigurationCompat instead")
@get:RequiresApi(28)
val enabled get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1
@Deprecated("Use SoftApConfigurationCompat instead")
suspend fun setEnabled(value: Boolean) = SettingsGlobalPut.int(SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0)
val defaultTimeout: Int get() {
val delay = if (Build.VERSION.SDK_INT >= 28) try {
val delay = try {
if (Build.VERSION.SDK_INT < 30) Resources.getSystem().run {
getInteger(getIdentifier("config_wifi_framework_soft_ap_timeout_delay", "integer", "android"))
} else {
val info = WifiApManager.resolvedActivity.activityInfo
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
resources.getInteger(resources.findIdentifier("config_wifiFrameworkSoftApShutDownTimeoutMilliseconds",
"integer", WifiApManager.RESOURCES_PACKAGE, info.packageName))
resources.getInteger(resources.findIdentifier(
"config_wifiFrameworkSoftApShutDownTimeoutMilliseconds", "integer",
WifiApManager.RESOURCES_PACKAGE, info.packageName))
}
} catch (e: RuntimeException) {
Timber.w(e)
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
}
@get:RequiresApi(31)
val defaultTimeoutBridged: Int get() = try {
val info = WifiApManager.resolvedActivity.activityInfo
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
resources.getInteger(resources.findIdentifier(
"config_wifiFrameworkSoftApShutDownIdleInstanceInBridgedModeTimeoutMillisecond", "integer",
WifiApManager.RESOURCES_PACKAGE, info.packageName))
} catch (e: RuntimeException) {
Timber.w(e)
MIN_SOFT_AP_TIMEOUT_DELAY_MS
}
}
private var noClient = true
@@ -74,7 +85,7 @@ class TetherTimeoutMonitor(private val timeout: Long = 0,
fun onClientsChanged(noClient: Boolean) {
this.noClient = noClient
if (!noClient) close() else if (timeoutJob == null) timeoutJob = GlobalScope.launch(context) {
delay(if (timeout == 0L) defaultTimeout.toLong() else timeout)
delay(if (timeout <= 0L) defaultTimeout.toLong() else timeout)
onTimeout()
}
}

View File

@@ -1,9 +1,9 @@
package be.mygod.vpnhotspot.net.monitor
import android.net.MacAddress
import androidx.collection.LongSparseArray
import androidx.collection.set
import be.mygod.vpnhotspot.net.IpDev
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.TrafficRecord
@@ -11,7 +11,12 @@ import be.mygod.vpnhotspot.util.Event2
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.parseNumericAddress
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.InetAddress
import java.util.concurrent.TimeUnit
@@ -23,8 +28,8 @@ object TrafficRecorder {
private val records = mutableMapOf<IpDev, TrafficRecord>()
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
fun register(ip: InetAddress, downstream: String, mac: MacAddressCompat) {
val record = TrafficRecord(mac = mac.addr, ip = ip, downstream = downstream)
fun register(ip: InetAddress, downstream: String, mac: MacAddress) {
val record = TrafficRecord(mac = mac, ip = ip, downstream = downstream)
AppDatabase.instance.trafficRecordDao.insert(record)
synchronized(this) {
val key = IpDev(ip, downstream)
@@ -107,9 +112,9 @@ object TrafficRecorder {
record.sentBytes = columns[1].toLong()
}
}
if (oldRecord.id != null) {
oldRecord.id?.let { oldId ->
check(records.put(key, record) == oldRecord)
oldRecords[oldRecord.id!!] = oldRecord
oldRecords[oldId] = oldRecord
}
}
else -> check(false)
@@ -130,6 +135,7 @@ object TrafficRecorder {
}
fun update(timeout: Boolean = false) {
synchronized(this) {
unscheduleUpdateLocked()
if (records.isEmpty()) return
val timestamp = System.currentTimeMillis()
if (!timeout && timestamp - lastUpdate <= 100) return
@@ -141,7 +147,6 @@ object TrafficRecorder {
SmartSnackbar.make(e).show()
}
lastUpdate = timestamp
updateJob = null
scheduleUpdateLocked()
}
}
@@ -156,5 +161,5 @@ object TrafficRecorder {
/**
* Possibly inefficient. Don't call this too often.
*/
fun isWorking(mac: MacAddressCompat) = records.values.any { it.mac == mac.addr }
fun isWorking(mac: MacAddress) = records.values.any { it.mac == mac }
}

View File

@@ -2,9 +2,6 @@ package be.mygod.vpnhotspot.net.monitor
import android.content.SharedPreferences
import android.net.LinkProperties
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import be.mygod.vpnhotspot.App.Companion.app
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -23,13 +20,6 @@ abstract class UpstreamMonitor {
}
private var monitor = generateMonitor()
fun networkRequestBuilder() = NetworkRequest.Builder().apply {
if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
}
}
fun registerCallback(callback: Callback) = synchronized(this) { monitor.registerCallback(callback) }
fun unregisterCallback(callback: Callback) = synchronized(this) { monitor.unregisterCallback(callback) }
@@ -56,15 +46,6 @@ abstract class UpstreamMonitor {
* Called if some possibly stacked interface is available
*/
fun onAvailable(properties: LinkProperties? = null)
/**
* Called on API 23- from DefaultNetworkMonitor. This indicates that there isn't a good way of telling the
* default network (see DefaultNetworkMonitor) and we are using rules at priority 22000
* (RULE_PRIORITY_DEFAULT_NETWORK) as our fallback rules, which would work fine until Android 9.0 broke it in
* commit: https://android.googlesource.com/platform/system/netd/+/758627c4d93392190b08e9aaea3bbbfb92a5f364
*/
fun onFallback() {
throw UnsupportedOperationException()
}
}
val callbacks = mutableSetOf<Callback>()

View File

@@ -5,15 +5,16 @@ import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
object VpnMonitor : UpstreamMonitor() {
private val request = networkRequestBuilder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
private val request = globalNetworkRequestBuilder().apply {
addTransportType(NetworkCapabilities.TRANSPORT_VPN)
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}.build()
private var registered = false
private val available = HashMap<Network, LinkProperties?>()
@@ -60,7 +61,7 @@ object VpnMonitor : UpstreamMonitor() {
callback.onAvailable(currentLinkProperties)
}
} else {
Services.connectivity.registerNetworkCallback(request, networkCallback)
Services.registerNetworkCallback(request, networkCallback)
registered = true
}
}

View File

@@ -1,5 +1,6 @@
package be.mygod.vpnhotspot.net.wifi
import android.net.MacAddress
import android.net.wifi.p2p.WifiP2pGroup
import be.mygod.vpnhotspot.RepeaterService
import be.mygod.vpnhotspot.net.MacAddressCompat
@@ -53,8 +54,8 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
var bssids = listOfNotNull(group?.owner?.deviceAddress, ownerAddress)
.distinct()
.filter {
val mac = MacAddress.fromString(it)
try {
val mac = MacAddressCompat.fromString(it)
mac != MacAddressCompat.ALL_ZEROS_ADDRESS && mac != MacAddressCompat.ANY_ADDRESS
} catch (_: IllegalArgumentException) {
false
@@ -75,7 +76,13 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
if (matchedBssid.isEmpty()) {
check(block.pskLine == null && block.psk == null)
if (match.groups[5] != null) {
block.psk = match.groupValues[5].apply { check(length in 8..63) }
block.psk = match.groupValues[5].apply {
when (length) {
in 8..63 -> { }
64 -> error("WPA-PSK hex not supported")
else -> error("Unknown length $length")
}
}
}
block.pskLine = block.size
} else if (bssids.any { matchedBssid.equals(it, true) }) {
@@ -120,7 +127,7 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
add("\tmode=3")
add("\tdisabled=2")
add("}")
if (target == null) target = this
target = this
})
}
content = Content(result, target!!, persistentMacLine, legacy)
@@ -135,13 +142,12 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
}
val psk by lazy { group?.passphrase ?: content.target.psk!! }
val bssid by lazy {
content.target.bssid?.let { MacAddressCompat.fromString(it) }
content.target.bssid?.let { MacAddress.fromString(it) }
}
suspend fun update(ssid: String, psk: String, bssid: MacAddressCompat?) {
suspend fun update(ssid: WifiSsidCompat, psk: String, bssid: MacAddress?) {
val (lines, block, persistentMacLine, legacy) = content
block[block.ssidLine!!] = "\tssid=" + ssid.toByteArray()
.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
block[block.ssidLine!!] = "\tssid=${ssid.hex}"
block[block.pskLine!!] = "\tpsk=\"$psk\"" // no control chars or weird stuff
if (bssid != null) {
persistentMacLine?.let { lines[it] = PERSISTENT_MAC + bssid }

View File

@@ -1,20 +1,27 @@
package be.mygod.vpnhotspot.net.wifi
import android.annotation.TargetApi
import android.os.Build
import android.os.Parcelable
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.util.LongConstantLookup
import be.mygod.vpnhotspot.util.UnblockCentral
import timber.log.Timber
@JvmInline
@RequiresApi(30)
value class SoftApCapability(val inner: Parcelable) {
companion object {
private val clazz by lazy { Class.forName("android.net.wifi.SoftApCapability") }
val clazz by lazy { Class.forName("android.net.wifi.SoftApCapability") }
private val getMaxSupportedClients by lazy { clazz.getDeclaredMethod("getMaxSupportedClients") }
private val areFeaturesSupported by lazy { clazz.getDeclaredMethod("areFeaturesSupported", Long::class.java) }
@get:RequiresApi(31)
private val getSupportedChannelList by lazy {
clazz.getDeclaredMethod("getSupportedChannelList", Int::class.java)
}
@get:RequiresApi(31)
@get:TargetApi(33)
private val getCountryCode by lazy { UnblockCentral.getCountryCode(clazz) }
@RequiresApi(31)
const val SOFTAP_FEATURE_BAND_24G_SUPPORTED = 32L
@@ -38,4 +45,11 @@ value class SoftApCapability(val inner: Parcelable) {
return supportedFeatures
}
fun getSupportedChannelList(band: Int) = getSupportedChannelList(inner, band) as IntArray
@get:RequiresApi(31)
val countryCode: String? get() = try {
getCountryCode(inner) as String?
} catch (e: ReflectiveOperationException) {
if (Build.VERSION.SDK_INT >= 33) Timber.w(e)
null
}
}

View File

@@ -3,59 +3,70 @@ package be.mygod.vpnhotspot.net.wifi
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.net.MacAddress
import android.net.wifi.ScanResult
import android.net.wifi.SoftApConfiguration
import android.net.wifi.WifiSsid
import android.os.Build
import android.os.Parcelable
import android.util.SparseIntArray
import androidx.annotation.RequiresApi
import androidx.core.os.BuildCompat
import androidx.core.util.keyIterator
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toCompat
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.requireSingleBand
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.setChannel
import be.mygod.vpnhotspot.net.wifi.WifiSsidCompat.Companion.toCompat
import be.mygod.vpnhotspot.util.ConstantLookup
import be.mygod.vpnhotspot.util.UnblockCentral
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.lang.reflect.InvocationTargetException
@Parcelize
data class SoftApConfigurationCompat(
var ssid: String? = null,
@Deprecated("Workaround for using inline class with Parcelize, use bssid")
var bssidAddr: Long? = null,
var passphrase: String? = null,
var isHiddenSsid: Boolean = false,
/**
* To read legacy band/channel pair, use [requireSingleBand]. For easy access, see [getChannel].
*
* You should probably set or modify this field directly only when you want to use bridged AP,
* see also [android.net.wifi.WifiManager.isBridgedApConcurrencySupported].
* Otherwise, use [optimizeChannels] or [setChannel].
*/
@TargetApi(23)
var channels: SparseIntArray = SparseIntArray(1).apply { put(BAND_2GHZ, 0) },
var securityType: Int = SoftApConfiguration.SECURITY_TYPE_OPEN,
@TargetApi(30)
var maxNumberOfClients: Int = 0,
@TargetApi(28)
var isAutoShutdownEnabled: Boolean = true,
@TargetApi(28)
var shutdownTimeoutMillis: Long = 0,
@TargetApi(30)
var isClientControlByUserEnabled: Boolean = false,
@RequiresApi(30)
var blockedClientList: List<MacAddress> = emptyList(),
@RequiresApi(30)
var allowedClientList: List<MacAddress> = emptyList(),
@TargetApi(31)
var macRandomizationSetting: Int = RANDOMIZATION_PERSISTENT,
@TargetApi(31)
var isBridgedModeOpportunisticShutdownEnabled: Boolean = true,
@TargetApi(31)
var isIeee80211axEnabled: Boolean = true,
@TargetApi(31)
var isUserConfiguration: Boolean = true,
var underlying: Parcelable? = null) : Parcelable {
var ssid: WifiSsidCompat? = null,
var bssid: MacAddress? = null,
var passphrase: String? = null,
var isHiddenSsid: Boolean = false,
/**
* You should probably set or modify this field directly only when you want to use bridged AP,
* see also [android.net.wifi.WifiManager.isBridgedApConcurrencySupported].
* Otherwise, use [requireSingleBand] and [setChannel].
*/
var channels: SparseIntArray = SparseIntArray(1).apply { append(BAND_2GHZ, 0) },
var securityType: Int = SoftApConfiguration.SECURITY_TYPE_OPEN,
@TargetApi(30)
var maxNumberOfClients: Int = 0,
var isAutoShutdownEnabled: Boolean = true,
var shutdownTimeoutMillis: Long = 0,
@TargetApi(30)
var isClientControlByUserEnabled: Boolean = false,
@RequiresApi(30)
var blockedClientList: List<MacAddress> = emptyList(),
@RequiresApi(30)
var allowedClientList: List<MacAddress> = emptyList(),
@TargetApi(31)
var macRandomizationSetting: Int = if (Build.VERSION.SDK_INT >= 33) {
RANDOMIZATION_NON_PERSISTENT
} else RANDOMIZATION_PERSISTENT,
@TargetApi(31)
var isBridgedModeOpportunisticShutdownEnabled: Boolean = true,
@TargetApi(31)
var isIeee80211axEnabled: Boolean = true,
@TargetApi(33)
var isIeee80211beEnabled: Boolean = true,
@TargetApi(31)
var isUserConfiguration: Boolean = true,
@TargetApi(33)
var bridgedModeOpportunisticShutdownTimeoutMillis: Long = -1L,
@TargetApi(33)
var vendorElements: List<ScanResult.InformationElement> = emptyList(),
@TargetApi(33)
var persistentRandomizedMacAddress: MacAddress? = null,
@TargetApi(33)
var allowedAcsChannels: Map<Int, Set<Int>> = emptyMap(),
@TargetApi(33)
var maxChannelBandwidth: Int = CHANNEL_WIDTH_AUTO,
var underlying: Parcelable? = null,
) : Parcelable {
companion object {
const val BAND_2GHZ = 1
const val BAND_5GHZ = 2
@@ -64,20 +75,32 @@ data class SoftApConfigurationCompat(
@TargetApi(31)
const val BAND_60GHZ = 8
const val BAND_LEGACY = BAND_2GHZ or BAND_5GHZ
@TargetApi(30)
const val BAND_ANY_30 = BAND_LEGACY or BAND_6GHZ
@TargetApi(31)
const val BAND_ANY_31 = BAND_ANY_30 or BAND_60GHZ
val BAND_TYPES by lazy {
if (BuildCompat.isAtLeastS()) try {
if (Build.VERSION.SDK_INT >= 31) try {
return@lazy UnblockCentral.SoftApConfiguration_BAND_TYPES
} catch (e: ReflectiveOperationException) {
Timber.w(e)
}
intArrayOf(BAND_2GHZ, BAND_5GHZ, BAND_6GHZ, BAND_60GHZ)
}
val bandLookup = ConstantLookup<SoftApConfiguration>("BAND_", null, "2GHZ", "5GHZ")
@RequiresApi(31)
val bandLookup = ConstantLookup<SoftApConfiguration>("BAND_")
@TargetApi(31)
const val RANDOMIZATION_NONE = 0
@TargetApi(31)
const val RANDOMIZATION_PERSISTENT = 1
@TargetApi(33)
const val RANDOMIZATION_NON_PERSISTENT = 2
@TargetApi(33)
const val CHANNEL_WIDTH_AUTO = -1
@TargetApi(30)
const val CHANNEL_WIDTH_INVALID = 0
fun isLegacyEitherBand(band: Int) = band and BAND_LEGACY == BAND_LEGACY
@@ -86,15 +109,19 @@ data class SoftApConfigurationCompat(
*/
private const val LEGACY_WPA2_PSK = 4
val securityTypes = arrayOf("OPEN", "WPA2-PSK", "WPA3-SAE", "WPA3-SAE Transition mode")
private val qrSanitizer = Regex("([\\\\\":;,])")
val securityTypes = arrayOf(
"OPEN",
"WPA2-PSK",
"WPA3-SAE Transition mode",
"WPA3-SAE",
"WPA3-OWE Transition",
"WPA3-OWE",
)
/**
* Based on:
* https://elixir.bootlin.com/linux/v5.12.8/source/net/wireless/util.c#L75
* TODO: update for Android 12
* https://cs.android.com/android/platform/superproject/+/master:frameworks/base/wifi/java/android/net/wifi/ScanResult.java;l=624;drc=f7ccda05642b55700d67a288462bada488fc7f5e
* https://cs.android.com/android/platform/superproject/+/master:packages/modules/Wifi/framework/java/android/net/wifi/ScanResult.java;l=789;drc=71d758698c45984d3f8de981bf98e56902480f16
*/
fun channelToFrequency(band: Int, chan: Int) = when (band) {
BAND_2GHZ -> when (chan) {
@@ -109,7 +136,7 @@ data class SoftApConfigurationCompat(
}
BAND_6GHZ -> when (chan) {
2 -> 5935
in 1..233 -> 5950 + chan * 5
in 1..253 -> 5950 + chan * 5
else -> throw IllegalArgumentException("Invalid 6GHz channel $chan")
}
BAND_60GHZ -> {
@@ -134,7 +161,6 @@ data class SoftApConfigurationCompat(
*
* https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/wifi/java/android/net/wifi/WifiConfiguration.java#242
*/
@get:RequiresApi(23)
@Suppress("DEPRECATION")
/**
* The band which AP resides on
@@ -142,7 +168,6 @@ data class SoftApConfigurationCompat(
* By default, 2G is chosen
*/
private val apBand by lazy { android.net.wifi.WifiConfiguration::class.java.getDeclaredField("apBand") }
@get:RequiresApi(23)
@Suppress("DEPRECATION")
/**
* The channel which AP resides on
@@ -154,6 +179,10 @@ data class SoftApConfigurationCompat(
android.net.wifi.WifiConfiguration::class.java.getDeclaredField("apChannel")
}
@get:RequiresApi(33)
private val getAllowedAcsChannels by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("getAllowedAcsChannels", Int::class.java)
}
@get:RequiresApi(30)
private val getAllowedClientList by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("getAllowedClientList")
@@ -164,6 +193,10 @@ data class SoftApConfigurationCompat(
private val getBlockedClientList by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("getBlockedClientList")
}
@get:RequiresApi(33)
private val getBridgedModeOpportunisticShutdownTimeoutMillis by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("getBridgedModeOpportunisticShutdownTimeoutMillis")
}
@get:RequiresApi(30)
private val getChannel by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("getChannel")
@@ -176,14 +209,26 @@ data class SoftApConfigurationCompat(
private val getMacRandomizationSetting by lazy @TargetApi(31) {
SoftApConfiguration::class.java.getDeclaredMethod("getMacRandomizationSetting")
}
@get:RequiresApi(33)
private val getMaxChannelBandwidth by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("getMaxChannelBandwidth")
}
@get:RequiresApi(30)
private val getMaxNumberOfClients by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("getMaxNumberOfClients")
}
@get:RequiresApi(33)
private val getPersistentRandomizedMacAddress by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("getPersistentRandomizedMacAddress")
}
@get:RequiresApi(30)
private val getShutdownTimeoutMillis by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("getShutdownTimeoutMillis")
}
@get:RequiresApi(33)
private val getVendorElements by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("getVendorElements")
}
@get:RequiresApi(30)
private val isAutoShutdownEnabled by lazy @TargetApi(30) {
SoftApConfiguration::class.java.getDeclaredMethod("isAutoShutdownEnabled")
@@ -200,6 +245,10 @@ data class SoftApConfigurationCompat(
private val isIeee80211axEnabled by lazy @TargetApi(31) {
SoftApConfiguration::class.java.getDeclaredMethod("isIeee80211axEnabled")
}
@get:RequiresApi(33)
private val isIeee80211beEnabled by lazy @TargetApi(33) {
SoftApConfiguration::class.java.getDeclaredMethod("isIeee80211beEnabled")
}
@get:RequiresApi(31)
private val isUserConfiguration by lazy @TargetApi(31) {
SoftApConfiguration::class.java.getDeclaredMethod("isUserConfiguration")
@@ -210,78 +259,106 @@ data class SoftApConfigurationCompat(
@get:RequiresApi(30)
private val newBuilder by lazy @TargetApi(30) { classBuilder.getConstructor(SoftApConfiguration::class.java) }
@get:RequiresApi(30)
private val build by lazy { classBuilder.getDeclaredMethod("build") }
private val build by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("build") }
@get:RequiresApi(33)
private val setAllowedAcsChannels by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setAllowedAcsChannels", Int::class.java, IntArray::class.java)
}
@get:RequiresApi(30)
private val setAllowedClientList by lazy {
private val setAllowedClientList by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setAllowedClientList", java.util.List::class.java)
}
@get:RequiresApi(30)
private val setAutoShutdownEnabled by lazy {
private val setAutoShutdownEnabled by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setAutoShutdownEnabled", Boolean::class.java)
}
@get:RequiresApi(30)
private val setBand by lazy { classBuilder.getDeclaredMethod("setBand", Int::class.java) }
private val setBand by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("setBand", Int::class.java) }
@get:RequiresApi(30)
private val setBlockedClientList by lazy {
private val setBlockedClientList by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setBlockedClientList", java.util.List::class.java)
}
@get:RequiresApi(31)
private val setBridgedModeOpportunisticShutdownEnabled by lazy {
private val setBridgedModeOpportunisticShutdownEnabled by lazy @TargetApi(31) {
classBuilder.getDeclaredMethod("setBridgedModeOpportunisticShutdownEnabled", Boolean::class.java)
}
@get:RequiresApi(33)
private val setBridgedModeOpportunisticShutdownTimeoutMillis by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setBridgedModeOpportunisticShutdownTimeoutMillis", Long::class.java)
}
@get:RequiresApi(30)
private val setBssid by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setBssid", MacAddress::class.java)
}
@get:RequiresApi(30)
private val setChannel by lazy {
private val setChannel by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setChannel", Int::class.java, Int::class.java)
}
@get:RequiresApi(31)
private val setChannels by lazy {
private val setChannels by lazy @TargetApi(31) {
classBuilder.getDeclaredMethod("setChannels", SparseIntArray::class.java)
}
@get:RequiresApi(30)
private val setClientControlByUserEnabled by lazy {
private val setClientControlByUserEnabled by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setClientControlByUserEnabled", Boolean::class.java)
}
@get:RequiresApi(30)
private val setHiddenSsid by lazy { classBuilder.getDeclaredMethod("setHiddenSsid", Boolean::class.java) }
private val setHiddenSsid by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setHiddenSsid", Boolean::class.java)
}
@get:RequiresApi(31)
private val setIeee80211axEnabled by lazy {
private val setIeee80211axEnabled by lazy @TargetApi(31) {
classBuilder.getDeclaredMethod("setIeee80211axEnabled", Boolean::class.java)
}
@get:RequiresApi(33)
private val setIeee80211beEnabled by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setIeee80211beEnabled", Boolean::class.java)
}
@get:RequiresApi(31)
private val setMacRandomizationSetting by lazy {
private val setMacRandomizationSetting by lazy @TargetApi(31) {
classBuilder.getDeclaredMethod("setMacRandomizationSetting", Int::class.java)
}
@get:RequiresApi(33)
private val setMaxChannelBandwidth by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setMaxChannelBandwidth", Int::class.java)
}
@get:RequiresApi(30)
private val setMaxNumberOfClients by lazy {
private val setMaxNumberOfClients by lazy @TargetApi(31) {
classBuilder.getDeclaredMethod("setMaxNumberOfClients", Int::class.java)
}
@get:RequiresApi(30)
private val setPassphrase by lazy {
private val setPassphrase by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setPassphrase", String::class.java, Int::class.java)
}
@get:RequiresApi(33)
private val setRandomizedMacAddress by lazy @TargetApi(33) {
UnblockCentral.setRandomizedMacAddress(classBuilder)
}
@get:RequiresApi(30)
private val setShutdownTimeoutMillis by lazy {
private val setShutdownTimeoutMillis by lazy @TargetApi(30) {
classBuilder.getDeclaredMethod("setShutdownTimeoutMillis", Long::class.java)
}
@get:RequiresApi(30)
private val setSsid by lazy { classBuilder.getDeclaredMethod("setSsid", String::class.java) }
@get:RequiresApi(31)
private val setUserConfiguration by lazy @TargetApi(31) { UnblockCentral.setUserConfiguration(classBuilder) }
private val setSsid by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("setSsid", String::class.java) }
@get:RequiresApi(33)
private val setVendorElements by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setVendorElements", java.util.List::class.java)
}
@get:RequiresApi(33)
private val setWifiSsid by lazy @TargetApi(33) {
classBuilder.getDeclaredMethod("setWifiSsid", WifiSsid::class.java)
}
@Deprecated("Class deprecated in framework")
@Suppress("DEPRECATION")
fun android.net.wifi.WifiConfiguration.toCompat() = SoftApConfigurationCompat(
SSID,
BSSID?.let { MacAddressCompat.fromString(it) }?.addr,
WifiSsidCompat.fromUtf8Text(SSID),
BSSID?.let { MacAddress.fromString(it) },
preSharedKey,
hiddenSSID,
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/wifi/java/android/net/wifi/SoftApConfToXmlMigrationUtil.java;l=87;drc=aa6527cf41671d1ed417b8ebdb6b3aa614f62344
SparseIntArray(1).apply {
if (Build.VERSION.SDK_INT < 23) put(BAND_LEGACY, 0) else put(when (val band = apBand.getInt(this)) {
SparseIntArray(1).also {
it.append(when (val band = apBand.getInt(this)) {
0 -> BAND_2GHZ
1 -> BAND_5GHZ
-1 -> BAND_LEGACY
@@ -302,77 +379,102 @@ data class SoftApConfigurationCompat(
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK
}
android.net.wifi.WifiConfiguration.KeyMgmt.SAE -> SoftApConfiguration.SECURITY_TYPE_WPA3_SAE
android.net.wifi.WifiConfiguration.KeyMgmt.OWE -> SoftApConfiguration.SECURITY_TYPE_WPA3_OWE
else -> android.net.wifi.WifiConfiguration.KeyMgmt.strings
.getOrElse<String>(selected) { "?" }.let {
throw IllegalArgumentException("Unrecognized key management $it ($selected)")
}
}
},
isAutoShutdownEnabled = if (Build.VERSION.SDK_INT >= 28) TetherTimeoutMonitor.enabled else false,
isAutoShutdownEnabled = TetherTimeoutMonitor.enabled,
underlying = this)
@RequiresApi(30)
@Suppress("UNCHECKED_CAST")
fun SoftApConfiguration.toCompat() = SoftApConfigurationCompat(
ssid,
bssid?.toCompat()?.addr,
passphrase,
isHiddenSsid,
if (BuildCompat.isAtLeastS()) getChannels(this) as SparseIntArray else SparseIntArray(1).apply {
put(getBand(this) as Int, getChannel(this) as Int)
},
securityType,
getMaxNumberOfClients(this) as Int,
isAutoShutdownEnabled(this) as Boolean,
getShutdownTimeoutMillis(this) as Long,
isClientControlByUserEnabled(this) as Boolean,
getBlockedClientList(this) as List<MacAddress>,
getAllowedClientList(this) as List<MacAddress>,
getMacRandomizationSetting(this) as Int,
isBridgedModeOpportunisticShutdownEnabled(this) as Boolean,
isIeee80211axEnabled(this) as Boolean,
isUserConfiguration(this) as Boolean,
this)
}
@Suppress("DEPRECATION")
inline var bssid: MacAddressCompat?
get() = bssidAddr?.let { MacAddressCompat(it) }
set(value) {
bssidAddr = value?.addr
if (Build.VERSION.SDK_INT >= 33) wifiSsid?.toCompat() else @Suppress("DEPRECATION") {
WifiSsidCompat.fromUtf8Text(ssid)
},
bssid,
passphrase,
isHiddenSsid,
if (Build.VERSION.SDK_INT >= 31) getChannels(this) as SparseIntArray else SparseIntArray(1).also {
it.append(getBand(this) as Int, getChannel(this) as Int)
},
securityType,
getMaxNumberOfClients(this) as Int,
isAutoShutdownEnabled(this) as Boolean,
getShutdownTimeoutMillis(this) as Long,
isClientControlByUserEnabled(this) as Boolean,
getBlockedClientList(this) as List<MacAddress>,
getAllowedClientList(this) as List<MacAddress>,
underlying = this,
).also {
if (Build.VERSION.SDK_INT < 31) return@also
it.macRandomizationSetting = getMacRandomizationSetting(this) as Int
it.isBridgedModeOpportunisticShutdownEnabled = isBridgedModeOpportunisticShutdownEnabled(this) as Boolean
it.isIeee80211axEnabled = isIeee80211axEnabled(this) as Boolean
it.isUserConfiguration = isUserConfiguration(this) as Boolean
if (Build.VERSION.SDK_INT < 33) return@also
it.isIeee80211beEnabled = isIeee80211beEnabled(this) as Boolean
it.bridgedModeOpportunisticShutdownTimeoutMillis =
getBridgedModeOpportunisticShutdownTimeoutMillis(this) as Long
it.vendorElements = getVendorElements(this) as List<ScanResult.InformationElement>
it.persistentRandomizedMacAddress = getPersistentRandomizedMacAddress(this) as MacAddress?
it.allowedAcsChannels = BAND_TYPES.map { bandType ->
try {
bandType to (getAllowedAcsChannels(this, bandType) as IntArray).toSet()
} catch (e: InvocationTargetException) {
if (e.targetException !is IllegalArgumentException) throw e
null
}
}.filterNotNull().toMap()
it.maxChannelBandwidth = getMaxChannelBandwidth(this) as Int
}
/**
* Only single band/channel can be supplied on API 23-30
*/
fun requireSingleBand(): Pair<Int, Int> {
require(channels.size() == 1) { "Unsupported number of bands configured" }
return channels.keyAt(0) to channels.valueAt(0)
}
fun getChannel(band: Int): Int {
var result = -1
for (b in channels.keyIterator()) if (band and b == band) {
require(result == -1) { "Duplicate band found" }
result = channels[b]
/**
* Only single band/channel can be supplied on API 23-30
*/
fun requireSingleBand(channels: SparseIntArray): Pair<Int, Int> {
require(channels.size() == 1) { "Unsupported number of bands configured" }
return channels.keyAt(0) to channels.valueAt(0)
}
return result
@RequiresApi(30)
private fun setChannelsCompat(builder: Any, channels: SparseIntArray) = if (Build.VERSION.SDK_INT < 31) {
val (band, channel) = requireSingleBand(channels)
if (channel == 0) setBand(builder, band) else setChannel(builder, channel, band)
} else setChannels(builder, channels)
@get:RequiresApi(30)
private val staticBuilder by lazy @TargetApi(30) { classBuilder.newInstance() }
@RequiresApi(30)
fun testPlatformValidity(channels: SparseIntArray) = setChannelsCompat(staticBuilder, channels)
@RequiresApi(30)
fun testPlatformValidity(bssid: MacAddress) = setBssid(staticBuilder, bssid)
@RequiresApi(33)
fun testPlatformValidity(vendorElements: List<ScanResult.InformationElement>) =
setVendorElements(staticBuilder, vendorElements)
@RequiresApi(33)
fun testPlatformValidity(band: Int, channels: IntArray) = setAllowedAcsChannels(staticBuilder, band, channels)
@RequiresApi(33)
fun testPlatformValidity(bandwidth: Int) = setMaxChannelBandwidth(staticBuilder, bandwidth)
@RequiresApi(30)
fun testPlatformTimeoutValidity(timeout: Long) = setShutdownTimeoutMillis(staticBuilder, timeout)
@RequiresApi(33)
fun testPlatformBridgedTimeoutValidity(timeout: Long) =
setBridgedModeOpportunisticShutdownTimeoutMillis(staticBuilder, timeout)
}
fun setChannel(channel: Int, band: Int = BAND_LEGACY) {
channels = SparseIntArray(1).apply { put(band, channel) }
}
fun optimizeChannels(channels: SparseIntArray = this.channels) {
this.channels = SparseIntArray(channels.size()).apply {
var setBand = 0
for (band in channels.keyIterator()) if (channels[band] == 0) setBand = setBand or band
if (setBand != 0) put(setBand, 0) // merge all bands into one
for (band in channels.keyIterator()) if (band and setBand == 0) put(band, channels[band])
channels = SparseIntArray(1).apply {
append(when {
channel <= 0 || band != BAND_LEGACY -> band
channel > 14 -> BAND_5GHZ
else -> BAND_2GHZ
}, channel)
}
}
fun setMacRandomizationEnabled(enabled: Boolean) {
macRandomizationSetting = if (enabled) RANDOMIZATION_PERSISTENT else RANDOMIZATION_NONE
}
/**
* Based on:
* https://android.googlesource.com/platform/packages/apps/Settings/+/android-5.0.0_r1/src/com/android/settings/wifi/WifiApDialog.java#88
@@ -383,25 +485,22 @@ data class SoftApConfigurationCompat(
@Deprecated("Class deprecated in framework, use toPlatform().toWifiConfiguration()")
@Suppress("DEPRECATION")
fun toWifiConfiguration(): android.net.wifi.WifiConfiguration {
val (band, channel) = requireSingleBand()
val (band, channel) = requireSingleBand(channels)
val wc = underlying as? android.net.wifi.WifiConfiguration
val result = if (wc == null) android.net.wifi.WifiConfiguration() else android.net.wifi.WifiConfiguration(wc)
val original = wc?.toCompat()
result.SSID = ssid
result.SSID = ssid?.toString()
result.preSharedKey = passphrase
result.hiddenSSID = isHiddenSsid
if (Build.VERSION.SDK_INT >= 23) {
apBand.setInt(result, when (band) {
BAND_2GHZ -> 0
BAND_5GHZ -> 1
else -> {
require(Build.VERSION.SDK_INT >= 28) { "A band must be specified on this platform" }
require(isLegacyEitherBand(band)) { "Convert fail, unsupported band setting :$band" }
-1
}
})
apChannel.setInt(result, channel)
} else require(isLegacyEitherBand(band)) { "Specifying band is unsupported on this platform" }
apBand.setInt(result, when (band) {
BAND_2GHZ -> 0
BAND_5GHZ -> 1
else -> {
require(isLegacyEitherBand(band)) { "Convert fail, unsupported band setting :$band" }
-1
}
})
apChannel.setInt(result, channel)
if (original?.securityType != securityType) {
result.allowedKeyManagement.clear()
result.allowedKeyManagement.set(when (securityType) {
@@ -411,6 +510,8 @@ data class SoftApConfigurationCompat(
// CHANGED: not actually converted in framework-wifi
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE,
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> android.net.wifi.WifiConfiguration.KeyMgmt.SAE
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION -> android.net.wifi.WifiConfiguration.KeyMgmt.OWE
else -> throw IllegalArgumentException("Convert fail, unsupported security type :$securityType")
})
result.allowedAuthAlgorithms.clear()
@@ -425,29 +526,59 @@ data class SoftApConfigurationCompat(
fun toPlatform(): SoftApConfiguration {
val sac = underlying as? SoftApConfiguration
val builder = if (sac == null) classBuilder.newInstance() else newBuilder.newInstance(sac)
setSsid(builder, ssid)
setPassphrase(builder, if (securityType == SoftApConfiguration.SECURITY_TYPE_OPEN) null else passphrase,
securityType)
if (BuildCompat.isAtLeastS()) setChannels(builder, channels) else {
val (band, channel) = requireSingleBand()
if (channel == 0) setBand(builder, band) else setChannel(builder, channel, band)
}
setBssid(builder, bssid?.toPlatform())
if (Build.VERSION.SDK_INT >= 33) {
setWifiSsid(builder, ssid?.toPlatform())
} else setSsid(builder, ssid?.toString())
setPassphrase(builder, when (securityType) {
SoftApConfiguration.SECURITY_TYPE_OPEN,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> null
else -> passphrase
}, securityType)
setChannelsCompat(builder, channels)
setBssid(builder,
if (Build.VERSION.SDK_INT < 31 || macRandomizationSetting == RANDOMIZATION_NONE) bssid else null)
setMaxNumberOfClients(builder, maxNumberOfClients)
setShutdownTimeoutMillis(builder, shutdownTimeoutMillis)
try {
setShutdownTimeoutMillis(builder, shutdownTimeoutMillis)
} catch (e: InvocationTargetException) {
if (e.targetException is IllegalArgumentException) try {
setShutdownTimeoutMillis(builder, -1 - shutdownTimeoutMillis)
} catch (e2: InvocationTargetException) {
e2.addSuppressed(e)
throw e2
} else throw e
}
setAutoShutdownEnabled(builder, isAutoShutdownEnabled)
setClientControlByUserEnabled(builder, isClientControlByUserEnabled)
setHiddenSsid(builder, isHiddenSsid)
setAllowedClientList(builder, allowedClientList)
setBlockedClientList(builder, blockedClientList)
if (BuildCompat.isAtLeastS()) {
if (Build.VERSION.SDK_INT >= 31) {
setMacRandomizationSetting(builder, macRandomizationSetting)
setBridgedModeOpportunisticShutdownEnabled(builder, isBridgedModeOpportunisticShutdownEnabled)
setIeee80211axEnabled(builder, isIeee80211axEnabled)
if (sac?.let { isUserConfiguration(it) as Boolean } != false != isUserConfiguration) try {
setUserConfiguration(builder, isUserConfiguration)
} catch (e: ReflectiveOperationException) {
Timber.w(e) // as far as we are concerned, this field is not used anywhere so ignore for now
if (Build.VERSION.SDK_INT >= 33) {
setIeee80211beEnabled(builder, isIeee80211beEnabled)
setBridgedModeOpportunisticShutdownTimeoutMillis(builder, bridgedModeOpportunisticShutdownTimeoutMillis)
setVendorElements(builder, vendorElements)
val needsUpdate = persistentRandomizedMacAddress != null && sac?.let {
getPersistentRandomizedMacAddress(it) as MacAddress
} != persistentRandomizedMacAddress
if (needsUpdate) try {
setRandomizedMacAddress(builder, persistentRandomizedMacAddress)
} catch (e: ReflectiveOperationException) {
Timber.w(e)
}
for (bandType in BAND_TYPES) {
val value = allowedAcsChannels[bandType] ?: emptySet()
try {
setAllowedAcsChannels(builder, bandType, value.toIntArray())
} catch (e: InvocationTargetException) {
if (value.isNotEmpty()) throw e
}
}
setMaxChannelBandwidth(builder, maxChannelBandwidth)
}
}
return build(builder) as SoftApConfiguration
@@ -458,21 +589,21 @@ data class SoftApConfigurationCompat(
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/4a5ff58/src/com/android/settings/wifi/dpp/WifiNetworkConfig.java#161
*/
fun toQrCode() = StringBuilder("WIFI:").apply {
fun String.sanitize() = qrSanitizer.replace(this) { "\\${it.groupValues[1]}" }
when (securityType) {
SoftApConfiguration.SECURITY_TYPE_OPEN -> { }
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK -> append("T:WPA;")
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
append("T:SAE;")
SoftApConfiguration.SECURITY_TYPE_OPEN, SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> { }
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
append("T:WPA;")
}
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE -> append("T:SAE;")
else -> throw IllegalArgumentException("Unsupported authentication type")
}
append("S:")
append(ssid!!.sanitize())
append(ssid!!.toMeCard())
append(';')
passphrase?.let { passphrase ->
append("P:")
append(passphrase.sanitize())
append(WifiSsidCompat.toMeCard(passphrase))
append(';')
}
if (isHiddenSsid) append("H:true;")

View File

@@ -12,7 +12,7 @@ import timber.log.Timber
@RequiresApi(30)
value class SoftApInfo(val inner: Parcelable) {
companion object {
private val clazz by lazy { Class.forName("android.net.wifi.SoftApInfo") }
val clazz by lazy { Class.forName("android.net.wifi.SoftApInfo") }
private val getFrequency by lazy { clazz.getDeclaredMethod("getFrequency") }
private val getBandwidth by lazy { clazz.getDeclaredMethod("getBandwidth") }
@get:RequiresApi(31)
@@ -30,7 +30,7 @@ value class SoftApInfo(val inner: Parcelable) {
val frequency get() = getFrequency(inner) as Int
val bandwidth get() = getBandwidth(inner) as Int
@get:RequiresApi(31)
val bssid get() = getBssid(inner) as MacAddress
val bssid get() = getBssid(inner) as MacAddress?
@get:RequiresApi(31)
val wifiStandard get() = getWifiStandard(inner) as Int
@get:RequiresApi(31)

View File

@@ -0,0 +1,27 @@
package be.mygod.vpnhotspot.net.wifi
import android.net.wifi.ScanResult
import androidx.annotation.RequiresApi
import timber.log.Timber
@RequiresApi(33)
object VendorElements {
fun serialize(input: List<ScanResult.InformationElement>) = input.joinToString("\n") { element ->
element.bytes.let { buffer ->
StringBuilder().apply {
while (buffer.hasRemaining()) append("%02x".format(buffer.get()))
}.toString()
}.also {
if (element.id != 221 || element.idExt != 0 || it.isEmpty()) Timber.w(Exception(
"Unexpected InformationElement ${element.id}, ${element.idExt}, $it"))
}
}
fun deserialize(input: CharSequence?) = (input ?: "").split("\n").map { line ->
if (line.isBlank()) return@map null
require(line.length % 2 == 0) { "Input should be hex: $line" }
(0 until line.length / 2).map {
Integer.parseInt(line.substring(it * 2, it * 2 + 2), 16).toByte()
}.toByteArray()
}.filterNotNull().map { ScanResult.InformationElement(221, 0, it) }
}

View File

@@ -1,13 +1,15 @@
package be.mygod.vpnhotspot.net.wifi
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.ClipData
import android.content.ClipDescription
import android.content.DialogInterface
import android.net.MacAddress
import android.net.wifi.SoftApConfiguration
import android.os.Build
import android.os.Parcelable
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.util.Base64
import android.util.SparseIntArray
@@ -16,10 +18,11 @@ import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Spinner
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.os.BuildCompat
import androidx.core.os.persistableBundleOf
import androidx.core.view.isGone
import be.mygod.librootkotlinx.toByteArray
import be.mygod.librootkotlinx.toParcelable
@@ -28,14 +31,16 @@ import be.mygod.vpnhotspot.App.Companion.app
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.RangeInput
import be.mygod.vpnhotspot.util.readableMessage
import be.mygod.vpnhotspot.util.showAllowingStateLoss
import be.mygod.vpnhotspot.widget.SmartSnackbar
import com.google.android.material.textfield.TextInputLayout
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
/**
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/39b4674/src/com/android/settings/wifi/WifiApDialog.java
@@ -48,26 +53,34 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
companion object {
private const val BASE64_FLAGS = Base64.NO_PADDING or Base64.NO_WRAP
private val nonMacChars = "[^0-9a-fA-F:]+".toRegex()
private val baseOptions by lazy { listOf(ChannelOption.Disabled, ChannelOption.Auto) }
private val channels2G by lazy {
baseOptions + (1..14).map { ChannelOption(it, SoftApConfigurationCompat.BAND_2GHZ) }
}
private val channels2G = (1..14).map { ChannelOption(SoftApConfigurationCompat.BAND_2GHZ, it) }
private val channels5G by lazy {
baseOptions + (1..196).map { ChannelOption(it, SoftApConfigurationCompat.BAND_5GHZ) }
}
@get:RequiresApi(30)
private val channels6G by lazy {
baseOptions + (1..233).map { ChannelOption(it, SoftApConfigurationCompat.BAND_6GHZ) }
}
@get:RequiresApi(31)
private val channels60G by lazy {
baseOptions + (1..6).map { ChannelOption(it, SoftApConfigurationCompat.BAND_60GHZ) }
channels2G + (1..196).map { ChannelOption(SoftApConfigurationCompat.BAND_5GHZ, it) }
}
private fun genAutoOptions(band: Int) = (1..band).filter { it and band == it }.map { ChannelOption(it) }
/**
* Source: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/c2fc6a1/service/java/com/android/server/wifi/p2p/SupplicantP2pIfaceHal.java#1396
*/
private val p2pChannels by lazy {
baseOptions + (15..165).map { ChannelOption(it, SoftApConfigurationCompat.BAND_5GHZ) }
private val p2pUnsafeOptions by lazy {
listOf(ChannelOption(SoftApConfigurationCompat.BAND_LEGACY)) +
channels2G + (15..165).map { ChannelOption(SoftApConfigurationCompat.BAND_5GHZ, it) }
}
private val p2pSafeOptions by lazy { genAutoOptions(SoftApConfigurationCompat.BAND_LEGACY) + channels5G }
private val softApOptions by lazy {
if (Build.VERSION.SDK_INT >= 30) {
genAutoOptions(SoftApConfigurationCompat.BAND_ANY_31) +
channels5G +
(1..253).map { ChannelOption(SoftApConfigurationCompat.BAND_6GHZ, it) } +
(1..6).map { ChannelOption(SoftApConfigurationCompat.BAND_60GHZ, it) }
} else p2pSafeOptions
}
@get:RequiresApi(30)
private val bandWidthOptions by lazy {
SoftApInfo.channelWidthLookup.lookup.let { lookup ->
Array(lookup.size()) { BandWidth(lookup.keyAt(it), lookup.valueAt(it).substring(14)) }.apply { sort() }
}
}
}
@@ -80,62 +93,99 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
*/
val p2pMode: Boolean = false) : Parcelable
private open class ChannelOption(val channel: Int = 0, private val band: Int = 0) {
private open class ChannelOption(val band: Int = 0, val channel: Int = 0) {
object Disabled : ChannelOption(-1) {
override fun toString() = app.getString(R.string.wifi_ap_choose_disabled)
}
object Auto : ChannelOption() {
override fun toString() = app.getString(R.string.wifi_ap_choose_auto)
}
override fun toString() = "${SoftApConfigurationCompat.channelToFrequency(band, channel)} MHz ($channel)"
override fun toString() = if (channel == 0) {
val format = DecimalFormat("#.#", DecimalFormatSymbols.getInstance(app.resources.configuration.locales[0]))
app.getString(R.string.wifi_ap_choose_G, arrayOf(
SoftApConfigurationCompat.BAND_2GHZ to 2.4,
SoftApConfigurationCompat.BAND_5GHZ to 5,
SoftApConfigurationCompat.BAND_6GHZ to 6,
SoftApConfigurationCompat.BAND_60GHZ to 60,
).filter { (mask, _) -> band and mask == mask }.joinToString("/") { (_, name) -> format.format(name) })
} else "${SoftApConfigurationCompat.channelToFrequency(band, channel)} MHz ($channel)"
}
private class BandWidth(val width: Int, val name: String = "") : Comparable<BandWidth> {
override fun compareTo(other: BandWidth) = width - other.width
override fun toString() = name
}
private lateinit var dialogView: DialogWifiApBinding
private lateinit var base: SoftApConfigurationCompat
private var pasted = false
private var started = false
private val currentChannels5G get() = if (arg.p2pMode && !RepeaterService.safeMode) p2pChannels else channels5G
private val currentChannels get() = when {
!arg.p2pMode -> softApOptions
RepeaterService.safeMode -> p2pSafeOptions
else -> p2pUnsafeOptions
}
private val acsList by lazy {
listOf(
Triple(SoftApConfigurationCompat.BAND_2GHZ, dialogView.acs2g, dialogView.acs2gWrapper),
Triple(SoftApConfigurationCompat.BAND_5GHZ, dialogView.acs5g, dialogView.acs5gWrapper),
Triple(SoftApConfigurationCompat.BAND_6GHZ, dialogView.acs6g, dialogView.acs6gWrapper),
)
}
override val ret get() = Arg(generateConfig())
private val hexToggleable get() = if (arg.p2pMode) !RepeaterService.safeMode else Build.VERSION.SDK_INT >= 33
private var hexSsid = false
set(value) {
field = value
dialogView.ssidWrapper.setEndIconActivated(value)
}
private val ssid get() =
if (hexSsid) WifiSsidCompat.fromHex(dialogView.ssid.text) else WifiSsidCompat.fromUtf8Text(dialogView.ssid.text)
private fun generateChannels() = SparseIntArray(2).apply {
if (!arg.p2pMode && Build.VERSION.SDK_INT >= 31) {
(dialogView.bandSecondary.selectedItem as ChannelOption?)?.apply { if (band >= 0) put(band, channel) }
}
(dialogView.bandPrimary.selectedItem as ChannelOption).apply { put(band, channel) }
}
private fun generateConfig(full: Boolean = true) = base.copy(
ssid = dialogView.ssid.text.toString(),
ssid = ssid,
passphrase = if (dialogView.password.length() != 0) dialogView.password.text.toString() else null).apply {
if (!arg.p2pMode) {
securityType = dialogView.security.selectedItemPosition
isHiddenSsid = dialogView.hiddenSsid.isChecked
}
if (full) @TargetApi(28) {
if (full) {
isAutoShutdownEnabled = dialogView.autoShutdown.isChecked
shutdownTimeoutMillis = dialogView.timeout.text.let { text ->
if (text.isNullOrEmpty()) 0 else text.toString().toLong()
}
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
val channels = SparseIntArray(4)
for ((band, spinner) in arrayOf(SoftApConfigurationCompat.BAND_2GHZ to dialogView.band2G,
SoftApConfigurationCompat.BAND_5GHZ to dialogView.band5G,
SoftApConfigurationCompat.BAND_6GHZ to dialogView.band6G,
SoftApConfigurationCompat.BAND_60GHZ to dialogView.band60G)) {
val channel = (spinner.selectedItem as ChannelOption?)?.channel
if (channel != null && channel >= 0) channels.put(band, channel)
}
if (!arg.p2pMode && BuildCompat.isAtLeastS() && dialogView.bridgedMode.isChecked) {
this.channels = channels
} else optimizeChannels(channels)
}
bssid = if (dialogView.bssid.length() != 0) {
MacAddressCompat.fromString(dialogView.bssid.text.toString())
} else null
channels = generateChannels()
maxNumberOfClients = dialogView.maxClient.text.let { text ->
if (text.isNullOrEmpty()) 0 else text.toString().toInt()
}
isClientControlByUserEnabled = dialogView.clientUserControl.isChecked
allowedClientList = (dialogView.allowedList.text ?: "").split(nonMacChars)
.filter { it.isNotEmpty() }.map { MacAddressCompat.fromString(it).toPlatform() }
.filter { it.isNotEmpty() }.map(MacAddress::fromString)
blockedClientList = (dialogView.blockedList.text ?: "").split(nonMacChars)
.filter { it.isNotEmpty() }.map { MacAddressCompat.fromString(it).toPlatform() }
setMacRandomizationEnabled(dialogView.macRandomization.isChecked)
.filter { it.isNotEmpty() }.map(MacAddress::fromString)
macRandomizationSetting = dialogView.macRandomization.selectedItemPosition
bssid = if ((arg.p2pMode || Build.VERSION.SDK_INT < 31 && macRandomizationSetting ==
SoftApConfigurationCompat.RANDOMIZATION_NONE) && dialogView.bssid.length() != 0) {
MacAddress.fromString(dialogView.bssid.text.toString())
} else null
isBridgedModeOpportunisticShutdownEnabled = dialogView.bridgedModeOpportunisticShutdown.isChecked
isIeee80211axEnabled = dialogView.ieee80211ax.isChecked
isIeee80211beEnabled = dialogView.ieee80211be.isChecked
isUserConfiguration = dialogView.userConfig.isChecked
bridgedModeOpportunisticShutdownTimeoutMillis = dialogView.bridgedTimeout.text.let { text ->
if (text.isNullOrEmpty()) -1L else text.toString().toLong()
}
vendorElements = VendorElements.deserialize(dialogView.vendorElements.text)
persistentRandomizedMacAddress = if (dialogView.persistentRandomizedMac.length() != 0) {
MacAddress.fromString(dialogView.persistentRandomizedMac.text.toString())
} else null
allowedAcsChannels = acsList.associate { (band, text, _) -> band to RangeInput.fromString(text.text) }
if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) {
maxChannelBandwidth = (dialogView.maxChannelBandwidth.selectedItem as BandWidth).width
}
}
}
@@ -148,6 +198,31 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
setNegativeButton(R.string.donations__button_close, null)
dialogView.toolbar.inflateMenu(R.menu.toolbar_configuration)
dialogView.toolbar.setOnMenuItemClickListener(this@WifiApDialogFragment)
dialogView.ssidWrapper.setLengthCounter {
try {
ssid?.bytes?.size ?: 0
} catch (_: IllegalArgumentException) {
0
}
}
if (hexToggleable) dialogView.ssidWrapper.apply {
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconOnClickListener {
val ssid = try {
ssid
} catch (_: IllegalArgumentException) {
return@setEndIconOnClickListener
}
val newText = if (hexSsid) ssid?.run {
decode().also { if (it == null) return@setEndIconOnClickListener }
} else ssid?.hex
hexSsid = !hexSsid
dialogView.ssid.setText(newText)
}
findViewById<View>(com.google.android.material.R.id.text_input_end_icon).apply {
tooltipText = contentDescription
}
}
if (!arg.readOnly) dialogView.ssid.addTextChangedListener(this@WifiApDialogFragment)
if (arg.p2pMode) dialogView.securityWrapper.isGone = true else dialogView.security.apply {
adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0,
@@ -157,99 +232,115 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) = error("Must select something")
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
dialogView.passwordWrapper.isGone = position == SoftApConfiguration.SECURITY_TYPE_OPEN
when (position) {
SoftApConfiguration.SECURITY_TYPE_OPEN,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> dialogView.passwordWrapper.isGone = true
else -> {
dialogView.passwordWrapper.isGone = false
if (position == SoftApConfiguration.SECURITY_TYPE_WPA3_SAE) {
dialogView.passwordWrapper.isCounterEnabled = false
dialogView.passwordWrapper.counterMaxLength = 0
dialogView.password.filters = emptyArray()
} else {
dialogView.passwordWrapper.isCounterEnabled = true
dialogView.passwordWrapper.counterMaxLength = 63
dialogView.password.filters = arrayOf(InputFilter.LengthFilter(63))
}
}
}
validate()
}
}
}
if (!arg.readOnly) dialogView.password.addTextChangedListener(this@WifiApDialogFragment)
if (!arg.p2pMode && Build.VERSION.SDK_INT < 28) dialogView.autoShutdown.isGone = true
if (arg.p2pMode || Build.VERSION.SDK_INT >= 30) {
dialogView.timeoutWrapper.helperText = getString(R.string.wifi_hotspot_timeout_default,
TetherTimeoutMonitor.defaultTimeout)
dialogView.timeout.addTextChangedListener(this@WifiApDialogFragment)
if (!arg.readOnly) dialogView.timeout.addTextChangedListener(this@WifiApDialogFragment)
} else dialogView.timeoutWrapper.isGone = true
fun Spinner.configure(options: List<ChannelOption>) {
adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0, options).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
onItemSelectedListener = this@WifiApDialogFragment
if (!arg.readOnly) onItemSelectedListener = this@WifiApDialogFragment
}
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
dialogView.band2G.configure(channels2G)
dialogView.band5G.configure(currentChannels5G)
} else {
dialogView.bandWrapper2G.isGone = true
dialogView.bandWrapper5G.isGone = true
}
if (Build.VERSION.SDK_INT >= 30 && !arg.p2pMode) dialogView.band6G.configure(channels6G)
else dialogView.bandWrapper6G.isGone = true
if (BuildCompat.isAtLeastS() && !arg.p2pMode) dialogView.band60G.configure(channels60G)
else dialogView.bandWrapper60G.isGone = true
dialogView.bssid.addTextChangedListener(this@WifiApDialogFragment)
if (arg.p2pMode) dialogView.hiddenSsid.isGone = true
if (arg.p2pMode || Build.VERSION.SDK_INT < 30) {
dialogView.maxClientWrapper.isGone = true
dialogView.clientUserControl.isGone = true
dialogView.blockedListWrapper.isGone = true
dialogView.allowedListWrapper.isGone = true
} else {
dialogView.bandPrimary.configure(currentChannels)
if (Build.VERSION.SDK_INT >= 31 && !arg.p2pMode) {
dialogView.bandSecondary.configure(listOf(ChannelOption.Disabled) + currentChannels)
} else dialogView.bandSecondary.isGone = true
if (arg.p2pMode || Build.VERSION.SDK_INT < 30) dialogView.accessControlGroup.isGone = true
else if (!arg.readOnly) {
dialogView.maxClient.addTextChangedListener(this@WifiApDialogFragment)
dialogView.blockedList.addTextChangedListener(this@WifiApDialogFragment)
dialogView.allowedList.addTextChangedListener(this@WifiApDialogFragment)
}
if (!arg.readOnly) dialogView.bssid.addTextChangedListener(this@WifiApDialogFragment)
if (arg.p2pMode) dialogView.hiddenSsid.isGone = true
if (arg.p2pMode && Build.VERSION.SDK_INT >= 29) dialogView.macRandomization.isEnabled = false
else if (arg.p2pMode || !BuildCompat.isAtLeastS()) dialogView.macRandomization.isGone = true
if (arg.p2pMode || !BuildCompat.isAtLeastS()) {
dialogView.bridgedMode.isGone = true
dialogView.bridgedModeOpportunisticShutdown.isGone = true
else if (arg.p2pMode || Build.VERSION.SDK_INT < 31) dialogView.macRandomizationWrapper.isGone = true
else dialogView.macRandomization.onItemSelectedListener = this@WifiApDialogFragment
if (arg.p2pMode || Build.VERSION.SDK_INT < 31) {
dialogView.ieee80211ax.isGone = true
dialogView.bridgedModeOpportunisticShutdown.isGone = true
dialogView.userConfig.isGone = true
dialogView.bridgedTimeoutWrapper.isGone = true
} else {
dialogView.bridgedTimeoutWrapper.helperText = getString(R.string.wifi_hotspot_timeout_default,
TetherTimeoutMonitor.defaultTimeoutBridged)
}
if (Build.VERSION.SDK_INT < 33) dialogView.vendorElementsWrapper.isGone = true
else if (!arg.readOnly) dialogView.vendorElements.addTextChangedListener(this@WifiApDialogFragment)
if (arg.p2pMode || Build.VERSION.SDK_INT < 33) {
dialogView.ieee80211be.isGone = true
dialogView.bridgedTimeout.isEnabled = false
dialogView.persistentRandomizedMacWrapper.isGone = true
for ((_, _, wrapper) in acsList) wrapper.isGone = true
dialogView.maxChannelBandwidthWrapper.isGone = true
} else {
dialogView.maxChannelBandwidth.adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0,
bandWidthOptions).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
if (!arg.readOnly) {
dialogView.bridgedTimeout.addTextChangedListener(this@WifiApDialogFragment)
dialogView.persistentRandomizedMac.addTextChangedListener(this@WifiApDialogFragment)
for ((_, text, _) in acsList) text.addTextChangedListener(this@WifiApDialogFragment)
dialogView.acs5g.addTextChangedListener(this@WifiApDialogFragment)
dialogView.acs6g.addTextChangedListener(this@WifiApDialogFragment)
dialogView.maxChannelBandwidth.onItemSelectedListener = this@WifiApDialogFragment
}
}
base = arg.configuration
populateFromConfiguration()
}
private fun locate(band: Int, channels: List<ChannelOption>): Int {
val channel = base.getChannel(band)
val selection = channels.indexOfFirst { it.channel == channel }
private fun locate(i: Int): Int {
val band = base.channels.keyAt(i)
val channel = base.channels.valueAt(i)
val selection = currentChannels.indexOfFirst { it.band == band && it.channel == channel }
return if (selection == -1) {
Timber.w(Exception("Unable to locate $band, $channel, ${arg.p2pMode && !RepeaterService.safeMode}"))
val msg = "Unable to locate $band, $channel, ${arg.p2pMode && !RepeaterService.safeMode}"
if (pasted || arg.p2pMode) Timber.w(msg) else Timber.w(Exception(msg))
0
} else selection
}
private var userBridgedMode = false
private fun setBridgedMode() {
var auto = 0
var set = 0
for (s in arrayOf(dialogView.band2G, dialogView.band5G, dialogView.band6G)) when (s.selectedItem) {
is ChannelOption.Auto -> auto = 1
!is ChannelOption.Disabled -> ++set
}
if (auto + set > 1) {
if (dialogView.bridgedMode.isEnabled) {
userBridgedMode = dialogView.bridgedMode.isChecked
dialogView.bridgedMode.isEnabled = false
dialogView.bridgedMode.isChecked = true
}
} else if (!dialogView.bridgedMode.isEnabled) {
dialogView.bridgedMode.isEnabled = true
dialogView.bridgedMode.isChecked = userBridgedMode
}
}
private fun populateFromConfiguration() {
dialogView.ssid.setText(base.ssid)
dialogView.ssid.setText(base.ssid.let { ssid ->
when {
ssid == null -> null
hexSsid -> ssid.hex
hexToggleable -> ssid.decode() ?: ssid.hex.also { hexSsid = true }
else -> ssid.toString()
}
})
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.band2G.setSelection(locate(SoftApConfigurationCompat.BAND_2GHZ, channels2G))
dialogView.band5G.setSelection(locate(SoftApConfigurationCompat.BAND_5GHZ, currentChannels5G))
dialogView.band6G.setSelection(locate(SoftApConfigurationCompat.BAND_6GHZ, channels6G))
dialogView.band60G.setSelection(locate(SoftApConfigurationCompat.BAND_60GHZ, channels60G))
userBridgedMode = base.channels.size() > 1
dialogView.bridgedMode.isChecked = userBridgedMode
setBridgedMode()
dialogView.timeout.setText(base.shutdownTimeoutMillis.let { if (it <= 0) "" else it.toString() })
dialogView.bandPrimary.setSelection(locate(0))
if (Build.VERSION.SDK_INT >= 31 && !arg.p2pMode) {
dialogView.bandSecondary.setSelection(if (base.channels.size() > 1) locate(1) + 1 else 0)
}
dialogView.bssid.setText(base.bssid?.toString())
dialogView.hiddenSsid.isChecked = base.isHiddenSsid
@@ -257,11 +348,22 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
dialogView.clientUserControl.isChecked = base.isClientControlByUserEnabled
dialogView.blockedList.setText(base.blockedClientList.joinToString("\n"))
dialogView.allowedList.setText(base.allowedClientList.joinToString("\n"))
dialogView.macRandomization.isChecked =
base.macRandomizationSetting == SoftApConfigurationCompat.RANDOMIZATION_PERSISTENT
dialogView.macRandomization.setSelection(base.macRandomizationSetting)
dialogView.bridgedModeOpportunisticShutdown.isChecked = base.isBridgedModeOpportunisticShutdownEnabled
dialogView.ieee80211ax.isChecked = base.isIeee80211axEnabled
dialogView.ieee80211be.isChecked = base.isIeee80211beEnabled
dialogView.userConfig.isChecked = base.isUserConfiguration
dialogView.bridgedTimeout.setText(base.bridgedModeOpportunisticShutdownTimeoutMillis.let {
if (it == -1L) "" else it.toString()
})
dialogView.vendorElements.setText(VendorElements.serialize(base.vendorElements))
dialogView.persistentRandomizedMac.setText(base.persistentRandomizedMacAddress?.toString())
for ((band, text, _) in acsList) text.setText(RangeInput.toString(base.allowedAcsChannels[band]))
if (Build.VERSION.SDK_INT >= 33) bandWidthOptions.binarySearch(BandWidth(base.maxChannelBandwidth)).let {
if (it < 0) {
Timber.w(Exception("Cannot locate bandwidth ${base.maxChannelBandwidth}"))
} else dialogView.maxChannelBandwidth.setSelection(it)
}
}
override fun onStart() {
@@ -270,64 +372,62 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
validate()
}
@TargetApi(28)
private fun validate() {
if (!started) return
val ssidLength = dialogView.ssid.text.toString().toByteArray().size
dialogView.ssidWrapper.error = if (arg.p2pMode && RepeaterService.safeMode && ssidLength < 9) {
requireContext().getString(R.string.settings_service_repeater_safe_mode_warning)
} else null
val (ssidOk, ssidError) = 0.let {
val ssid = try {
ssid
} catch (e: IllegalArgumentException) {
return@let false to e.readableMessage
}
val ssidLength = ssid?.bytes?.size ?: 0
if (ssidLength in 1..32) true to if (arg.p2pMode && RepeaterService.safeMode && ssidLength < 9) {
requireContext().getString(R.string.settings_service_repeater_safe_mode_warning)
} else null else false to " "
}
dialogView.ssidWrapper.error = ssidError
val selectedSecurity = if (arg.p2pMode) {
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK
} else dialogView.security.selectedItemPosition
// see also: https://android.googlesource.com/platform/frameworks/base/+/92c8f59/wifi/java/android/net/wifi/SoftApConfiguration.java#688
val passwordValid = when (selectedSecurity) {
SoftApConfiguration.SECURITY_TYPE_OPEN,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> true
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
dialogView.password.length() >= 8
dialogView.password.length() in 8..63
}
else -> true // do not try to validate
else -> dialogView.password.length() > 0
}
dialogView.passwordWrapper.error = if (passwordValid) null else " "
val timeoutError = dialogView.timeout.text.let { text ->
if (text.isNullOrEmpty()) null else try {
text.toString().toLong()
SoftApConfigurationCompat.testPlatformTimeoutValidity(text.toString().toLong())
null
} catch (e: NumberFormatException) {
} catch (e: Exception) {
e.readableMessage
}
}
dialogView.timeoutWrapper.error = timeoutError
val isBandValid = when {
arg.p2pMode || Build.VERSION.SDK_INT in 23 until 30 -> {
val option5G = dialogView.band5G.selectedItem
when (dialogView.band2G.selectedItem) {
is ChannelOption.Disabled -> option5G !is ChannelOption.Disabled &&
(!arg.p2pMode || RepeaterService.safeMode || option5G !is ChannelOption.Auto)
is ChannelOption.Auto ->
(arg.p2pMode || Build.VERSION.SDK_INT >= 28) && option5G is ChannelOption.Auto ||
(!arg.p2pMode || RepeaterService.safeMode) && option5G is ChannelOption.Disabled
else -> option5G is ChannelOption.Disabled
}
val bandError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 30) {
try {
SoftApConfigurationCompat.testPlatformValidity(generateChannels())
null
} catch (e: Exception) {
e.readableMessage
}
Build.VERSION.SDK_INT == 30 && !BuildCompat.isAtLeastS() -> {
var expected = 1
var set = 0
for (s in arrayOf(dialogView.band2G, dialogView.band5G, dialogView.band6G)) when (s.selectedItem) {
is ChannelOption.Auto -> expected = 0
!is ChannelOption.Disabled -> ++set
}
set == expected
}
else -> {
setBridgedMode()
true
}
}
} else null
dialogView.bandError.isGone = bandError.isNullOrEmpty()
dialogView.bandError.text = bandError
val hideBssid = !arg.p2pMode && Build.VERSION.SDK_INT >= 31 &&
dialogView.macRandomization.selectedItemPosition != SoftApConfigurationCompat.RANDOMIZATION_NONE
dialogView.bssidWrapper.isGone = hideBssid
dialogView.bssidWrapper.error = null
val bssidValid = dialogView.bssid.length() == 0 || try {
MacAddressCompat.fromString(dialogView.bssid.text.toString())
val bssidValid = hideBssid || dialogView.bssid.length() == 0 || try {
val mac = MacAddress.fromString(dialogView.bssid.text.toString())
if (Build.VERSION.SDK_INT >= 30 && !arg.p2pMode) SoftApConfigurationCompat.testPlatformValidity(mac)
true
} catch (e: IllegalArgumentException) {
} catch (e: Exception) {
dialogView.bssidWrapper.error = e.readableMessage
false
}
@@ -340,26 +440,80 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
}
}
dialogView.maxClientWrapper.error = maxClientError
val blockedListError = try {
(dialogView.blockedList.text ?: "").split(nonMacChars)
.filter { it.isNotEmpty() }.forEach { MacAddressCompat.fromString(it).toPlatform() }
null
} catch (e: IllegalArgumentException) {
e.readableMessage
val listsNoError = if (Build.VERSION.SDK_INT >= 30) {
val (blockedList, blockedListError) = try {
(dialogView.blockedList.text ?: "").split(nonMacChars).filter { it.isNotEmpty() }
.map(MacAddress::fromString).toSet() to null
} catch (e: IllegalArgumentException) {
null to e.readableMessage
}
dialogView.blockedListWrapper.error = blockedListError
val allowedListError = try {
(dialogView.allowedList.text ?: "").split(nonMacChars).filter { it.isNotEmpty() }.forEach {
val mac = MacAddress.fromString(it)
require(blockedList?.contains(mac) != true) { "A MAC address exists in both client lists" }
}
null
} catch (e: IllegalArgumentException) {
e.readableMessage
}
dialogView.allowedListWrapper.error = allowedListError
blockedListError == null && allowedListError == null
} else true
val bridgedTimeoutError = dialogView.bridgedTimeout.text.let { text ->
if (text.isNullOrEmpty()) null else try {
SoftApConfigurationCompat.testPlatformBridgedTimeoutValidity(text.toString().toLong())
null
} catch (e: Exception) {
e.readableMessage
}
}
dialogView.blockedListWrapper.error = blockedListError
val allowedListError = try {
(dialogView.allowedList.text ?: "").split(nonMacChars)
.filter { it.isNotEmpty() }.forEach { MacAddressCompat.fromString(it).toPlatform() }
null
dialogView.bridgedTimeoutWrapper.error = bridgedTimeoutError
val vendorElementsError = if (Build.VERSION.SDK_INT >= 33) {
try {
VendorElements.deserialize(dialogView.vendorElements.text).also {
if (!arg.p2pMode) SoftApConfigurationCompat.testPlatformValidity(it)
}
null
} catch (e: Exception) {
e.readableMessage
}
} else null
dialogView.vendorElementsWrapper.error = vendorElementsError
dialogView.persistentRandomizedMacWrapper.error = null
val persistentRandomizedMacValid = dialogView.persistentRandomizedMac.length() == 0 || try {
MacAddress.fromString(dialogView.persistentRandomizedMac.text.toString())
true
} catch (e: IllegalArgumentException) {
e.readableMessage
dialogView.persistentRandomizedMacWrapper.error = e.readableMessage
false
}
dialogView.allowedListWrapper.error = allowedListError
val canCopy = timeoutError == null && bssidValid && maxClientError == null && blockedListError == null &&
allowedListError == null
val acsNoError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) acsList.all { (band, text, wrapper) ->
try {
wrapper.error = null
SoftApConfigurationCompat.testPlatformValidity(band, RangeInput.fromString(text.text).toIntArray())
true
} catch (e: Exception) {
wrapper.error = e.readableMessage
false
}
} else true
val bandwidthError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) {
try {
SoftApConfigurationCompat.testPlatformValidity(
(dialogView.maxChannelBandwidth.selectedItem as BandWidth).width)
null
} catch (e: Exception) {
e.readableMessage
}
} else null
dialogView.maxChannelBandwidthError.isGone = bandwidthError.isNullOrEmpty()
dialogView.maxChannelBandwidthError.text = bandwidthError
val canCopy = timeoutError == null && bssidValid && maxClientError == null && listsNoError &&
bridgedTimeoutError == null && vendorElementsError == null && persistentRandomizedMacValid &&
acsNoError && bandwidthError == null
(dialog as? AlertDialog)?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled =
ssidLength in 1..32 && passwordValid && isBandValid && canCopy
ssidOk && passwordValid && bandError == null && canCopy
dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = canCopy
}
@@ -372,10 +526,15 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
override fun onMenuItemClick(item: MenuItem?): Boolean {
return when (item?.itemId) {
android.R.id.copy -> {
android.R.id.copy -> try {
app.clipboard.setPrimaryClip(ClipData.newPlainText(null,
Base64.encodeToString(generateConfig().toByteArray(), BASE64_FLAGS)))
Base64.encodeToString(generateConfig().toByteArray(), BASE64_FLAGS)).apply {
description.extras = persistableBundleOf(ClipDescription.EXTRA_IS_SENSITIVE to true)
})
true
} catch (e: RuntimeException) {
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
false
}
android.R.id.paste -> try {
app.clipboard.primaryClip?.getItemAt(0)?.text?.apply {
@@ -385,12 +544,13 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
arg.configuration.underlying?.let { check(it.javaClass == newUnderlying.javaClass) }
} else config.underlying = arg.configuration.underlying
base = config
pasted = true
populateFromConfiguration()
}
}
true
} catch (e: IllegalArgumentException) {
SmartSnackbar.make(e).show()
} catch (e: RuntimeException) {
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
false
}
R.id.share_qr -> {

View File

@@ -10,13 +10,8 @@ import android.os.Build
import android.os.Handler
import android.os.Parcelable
import androidx.annotation.RequiresApi
import androidx.core.os.BuildCompat
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
import be.mygod.vpnhotspot.util.ConstantLookup
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.callSuper
import be.mygod.vpnhotspot.util.findIdentifier
import be.mygod.vpnhotspot.util.*
import timber.log.Timber
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
@@ -39,17 +34,22 @@ object WifiApManager {
PackageManager.MATCH_SYSTEM_ONLY).single()
private const val CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED = "config_wifi_p2p_mac_randomization_supported"
val p2pMacRandomizationSupported get() = when (Build.VERSION.SDK_INT) {
29 -> Resources.getSystem().run {
getBoolean(getIdentifier(CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED, "bool", "android"))
val p2pMacRandomizationSupported get() = try {
when (Build.VERSION.SDK_INT) {
29 -> Resources.getSystem().run {
getBoolean(getIdentifier(CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED, "bool", "android"))
}
in 30..Int.MAX_VALUE -> @TargetApi(30) {
val info = resolvedActivity.activityInfo
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
resources.getBoolean(resources.findIdentifier(CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED, "bool",
RESOURCES_PACKAGE, info.packageName))
}
else -> false
}
in 30..Int.MAX_VALUE -> @TargetApi(30) {
val info = resolvedActivity.activityInfo
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
resources.getBoolean(resources.findIdentifier(CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED, "bool",
RESOURCES_PACKAGE, info.packageName))
}
else -> false
} catch (e: RuntimeException) {
Timber.w(e)
false
}
@get:RequiresApi(30)
@@ -59,6 +59,92 @@ object WifiApManager {
@get:RequiresApi(30)
val isApMacRandomizationSupported get() = apMacRandomizationSupported(Services.wifi) as Boolean
/**
* Broadcast intent action indicating that Wi-Fi AP has been enabled, disabled,
* enabling, disabling, or failed.
*/
const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED"
/**
* The lookup key for an int that indicates whether Wi-Fi AP is enabled,
* disabled, enabling, disabling, or failed. Retrieve it with [Intent.getIntExtra].
*
* @see WIFI_AP_STATE_DISABLED
* @see WIFI_AP_STATE_DISABLING
* @see WIFI_AP_STATE_ENABLED
* @see WIFI_AP_STATE_ENABLING
* @see WIFI_AP_STATE_FAILED
*/
private const val EXTRA_WIFI_AP_STATE = "wifi_state"
/**
* An extra containing the int error code for Soft AP start failure.
* Can be obtained from the [WIFI_AP_STATE_CHANGED_ACTION] using [Intent.getIntExtra].
* This extra will only be attached if [EXTRA_WIFI_AP_STATE] is
* attached and is equal to [WIFI_AP_STATE_FAILED].
*
* The error code will be one of:
* {@link #SAP_START_FAILURE_GENERAL},
* {@link #SAP_START_FAILURE_NO_CHANNEL},
* {@link #SAP_START_FAILURE_UNSUPPORTED_CONFIGURATION}
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/wifi/java/android/net/wifi/WifiManager.java#210
*/
val EXTRA_WIFI_AP_FAILURE_REASON get() =
if (Build.VERSION.SDK_INT >= 30) "android.net.wifi.extra.WIFI_AP_FAILURE_REASON" else "wifi_ap_error_code"
/**
* The lookup key for a String extra that stores the interface name used for the Soft AP.
* This extra is included in the broadcast [WIFI_AP_STATE_CHANGED_ACTION].
* Retrieve its value with [Intent.getStringExtra].
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/android-8.0.0_r1/wifi/java/android/net/wifi/WifiManager.java#413
*/
val EXTRA_WIFI_AP_INTERFACE_NAME get() =
if (Build.VERSION.SDK_INT >= 30) "android.net.wifi.extra.WIFI_AP_INTERFACE_NAME" else "wifi_ap_interface_name"
fun checkWifiApState(state: Int) = if (state < WIFI_AP_STATE_DISABLING || state > WIFI_AP_STATE_FAILED) {
Timber.w(Exception("Unknown state $state"))
false
} else true
val Intent.wifiApState get() =
getIntExtra(EXTRA_WIFI_AP_STATE, WIFI_AP_STATE_DISABLED).also { checkWifiApState(it) }
/**
* Wi-Fi AP is currently being disabled. The state will change to
* [WIFI_AP_STATE_DISABLED] if it finishes successfully.
*
* @see WIFI_AP_STATE_CHANGED_ACTION
* @see #getWifiApState()
*/
const val WIFI_AP_STATE_DISABLING = 10
/**
* Wi-Fi AP is disabled.
*
* @see WIFI_AP_STATE_CHANGED_ACTION
* @see #getWifiState()
*/
const val WIFI_AP_STATE_DISABLED = 11
/**
* Wi-Fi AP is currently being enabled. The state will change to
* {@link #WIFI_AP_STATE_ENABLED} if it finishes successfully.
*
* @see WIFI_AP_STATE_CHANGED_ACTION
* @see #getWifiApState()
*/
const val WIFI_AP_STATE_ENABLING = 12
/**
* Wi-Fi AP is enabled.
*
* @see WIFI_AP_STATE_CHANGED_ACTION
* @see #getWifiApState()
*/
const val WIFI_AP_STATE_ENABLED = 13
/**
* Wi-Fi AP is in a failed state. This state will occur when an error occurs during
* enabling or disabling
*
* @see WIFI_AP_STATE_CHANGED_ACTION
* @see #getWifiApState()
*/
const val WIFI_AP_STATE_FAILED = 14
private val getWifiApConfiguration by lazy { WifiManager::class.java.getDeclaredMethod("getWifiApConfiguration") }
@Suppress("DEPRECATION")
private val setWifiApConfiguration by lazy {
@@ -72,29 +158,29 @@ object WifiApManager {
WifiManager::class.java.getDeclaredMethod("setSoftApConfiguration", SoftApConfiguration::class.java)
}
@get:RequiresApi(30)
val configuration get() = getSoftApConfiguration(Services.wifi) as SoftApConfiguration
/**
* Requires NETWORK_SETTINGS permission (or root) on API 30+, and OVERRIDE_WIFI_CONFIG on API 29-.
*/
val configurationCompat get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
(getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
?: SoftApConfigurationCompat()
} else configuration.toCompat()
fun setConfigurationCompat(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT >= 30) {
setSoftApConfiguration(Services.wifi, value.toPlatform())
} else @Suppress("DEPRECATION") {
setWifiApConfiguration(Services.wifi, value.toWifiConfiguration())
}) as Boolean
@Deprecated("Use configuration instead", ReplaceWith("configuration"))
@Suppress("DEPRECATION")
val configurationLegacy get() = getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?
/**
* Requires NETWORK_SETTINGS permission (or root).
*/
@get:RequiresApi(30)
val configuration get() = getSoftApConfiguration(Services.wifi) as SoftApConfiguration
@Deprecated("Use SoftApConfiguration instead")
@Suppress("DEPRECATION")
fun setConfiguration(value: android.net.wifi.WifiConfiguration?) =
setWifiApConfiguration(Services.wifi, value) as Boolean
fun setConfiguration(value: SoftApConfiguration) = setSoftApConfiguration(Services.wifi, value) as Boolean
@RequiresApi(28)
interface SoftApCallbackCompat {
/**
* Called when soft AP state changes.
*
* @param state the new AP state. One of {@link #WIFI_AP_STATE_DISABLED},
* {@link #WIFI_AP_STATE_DISABLING}, {@link #WIFI_AP_STATE_ENABLED},
* {@link #WIFI_AP_STATE_ENABLING}, {@link #WIFI_AP_STATE_FAILED}
* @param state the new AP state. One of [WIFI_AP_STATE_DISABLED], [WIFI_AP_STATE_DISABLING],
* [WIFI_AP_STATE_ENABLED], [WIFI_AP_STATE_ENABLING], [WIFI_AP_STATE_FAILED]
* @param failureReason reason when in failed state. One of
* {@link #SAP_START_FAILURE_GENERAL},
* {@link #SAP_START_FAILURE_NO_CHANNEL},
@@ -150,7 +236,6 @@ object WifiApManager {
@RequiresApi(30)
fun onBlockedClientConnecting(client: Parcelable, blockedReason: Int) { }
}
@RequiresApi(28)
val failureReasonLookup = ConstantLookup<WifiManager>("SAP_START_FAILURE_", "GENERAL", "NO_CHANNEL")
@get:RequiresApi(30)
val clientBlockLookup by lazy { ConstantLookup<WifiManager>("SAP_CLIENT_") }
@@ -166,7 +251,6 @@ object WifiApManager {
WifiManager::class.java.getDeclaredMethod("unregisterSoftApCallback", interfaceSoftApCallback)
}
@RequiresApi(28)
fun registerSoftApCallback(callback: SoftApCallbackCompat, executor: Executor): Any {
val proxy = Proxy.newProxyInstance(interfaceSoftApCallback.classLoader,
arrayOf(interfaceSoftApCallback), object : InvocationHandler {
@@ -177,55 +261,36 @@ object WifiApManager {
} else invokeActual(proxy, method, args)
private fun invokeActual(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
val noArgs = args?.size ?: 0
return when (val name = method.name) {
"onStateChanged" -> {
if (noArgs != 2) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
return when {
method.matches("onStateChanged", Integer.TYPE, Integer.TYPE) -> {
callback.onStateChanged(args!![0] as Int, args[1] as Int)
}
"onNumClientsChanged" -> @Suppress("DEPRECATION") {
method.matches("onNumClientsChanged", Integer.TYPE) -> {
if (Build.VERSION.SDK_INT >= 30) Timber.w(Exception("Unexpected onNumClientsChanged"))
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
callback.onNumClientsChanged(args!![0] as Int)
}
"onConnectedClientsChanged" -> @TargetApi(30) {
method.matches1<java.util.List<*>>("onConnectedClientsChanged") -> @TargetApi(30) {
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onConnectedClientsChanged"))
@Suppress("UNCHECKED_CAST")
when (noArgs) {
1 -> callback.onConnectedClientsChanged(args!![0] as List<Parcelable>)
2 -> null // we use the old method which returns all clients in one call
else -> {
Timber.w("Unexpected args for $name: ${args?.contentToString()}")
null
}
}
callback.onConnectedClientsChanged(args!![0] as List<Parcelable>)
}
"onInfoChanged" -> @TargetApi(30) {
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
method.matches1<java.util.List<*>>("onInfoChanged") -> @TargetApi(31) {
if (Build.VERSION.SDK_INT < 31) Timber.w(Exception("Unexpected onInfoChanged API 31+"))
@Suppress("UNCHECKED_CAST")
callback.onInfoChanged(args!![0] as List<Parcelable>)
}
Build.VERSION.SDK_INT >= 30 && method.matches("onInfoChanged", SoftApInfo.clazz) -> {
if (Build.VERSION.SDK_INT >= 31) return null // ignore old version calls
val arg = args!![0]
if (arg is List<*>) {
if (!BuildCompat.isAtLeastS()) Timber.w(Exception("Unexpected onInfoChanged API 31+"))
@Suppress("UNCHECKED_CAST")
callback.onInfoChanged(arg as List<Parcelable>)
} else {
when (Build.VERSION.SDK_INT) {
30 -> { }
in 31..Int.MAX_VALUE -> return null // ignore old version calls
else -> Timber.w(Exception("Unexpected onInfoChanged API 30"))
}
val info = SoftApInfo(arg as Parcelable)
callback.onInfoChanged( // check for legacy empty info with CHANNEL_WIDTH_INVALID
if (info.frequency == 0 && info.bandwidth == 0) emptyList() else listOf(arg))
}
val info = SoftApInfo(arg as Parcelable)
callback.onInfoChanged(if (info.frequency == 0 && info.bandwidth ==
SoftApConfigurationCompat.CHANNEL_WIDTH_INVALID) emptyList() else listOf(arg))
}
"onCapabilityChanged" -> @TargetApi(30) {
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onCapabilityChanged"))
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
Build.VERSION.SDK_INT >= 30 && method.matches("onCapabilityChanged", SoftApCapability.clazz) -> {
callback.onCapabilityChanged(args!![0] as Parcelable)
}
"onBlockedClientConnecting" -> @TargetApi(30) {
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onBlockedClientConnecting"))
if (noArgs != 2) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
Build.VERSION.SDK_INT >= 30 && method.matches("onBlockedClientConnecting", WifiClient.clazz,
Int::class.java) -> {
callback.onBlockedClientConnecting(args!![0] as Parcelable, args[1] as Int)
}
else -> callSuper(interfaceSoftApCallback, proxy, method, args)
@@ -237,7 +302,6 @@ object WifiApManager {
} else registerSoftApCallback(Services.wifi, proxy, null)
return proxy
}
@RequiresApi(28)
fun unregisterSoftApCallback(key: Any) = unregisterSoftApCallback(Services.wifi, key)
@get:RequiresApi(30)
@@ -253,43 +317,9 @@ object WifiApManager {
private val cancelLocalOnlyHotspotRequest by lazy {
WifiManager::class.java.getDeclaredMethod("cancelLocalOnlyHotspotRequest")
}
@RequiresApi(26)
/**
* This is the only way to unregister requests besides app exiting.
* Therefore, we are happy with crashing the app if reflection fails.
*/
fun cancelLocalOnlyHotspotRequest() = cancelLocalOnlyHotspotRequest(Services.wifi)
@Suppress("DEPRECATION")
private val setWifiApEnabled by lazy {
WifiManager::class.java.getDeclaredMethod("setWifiApEnabled",
android.net.wifi.WifiConfiguration::class.java, Boolean::class.java)
}
/**
* Start AccessPoint mode with the specified
* configuration. If the radio is already running in
* AP mode, update the new configuration
* Note that starting in access point mode disables station
* mode operation
* @param wifiConfig SSID, security and channel details as
* part of WifiConfiguration
* @return {@code true} if the operation succeeds, {@code false} otherwise
*/
@Suppress("DEPRECATION")
private fun WifiManager.setWifiApEnabled(wifiConfig: android.net.wifi.WifiConfiguration?, enabled: Boolean) =
setWifiApEnabled(this, wifiConfig, enabled) as Boolean
/**
* Although the functionalities were removed in API 26, it is already not functioning correctly on API 25.
*
* See also: https://android.googlesource.com/platform/frameworks/base/+/5c0b10a4a9eecc5307bb89a271221f2b20448797%5E%21/
*/
@Suppress("DEPRECATION")
@Deprecated("Not usable since API 26, malfunctioning on API 25")
fun start(wifiConfig: android.net.wifi.WifiConfiguration? = null) {
Services.wifi.isWifiEnabled = false
Services.wifi.setWifiApEnabled(wifiConfig, true)
}
@Suppress("DEPRECATION")
@Deprecated("Not usable since API 26")
fun stop() {
Services.wifi.setWifiApEnabled(null, false)
Services.wifi.isWifiEnabled = true
}
}

View File

@@ -11,7 +11,7 @@ import timber.log.Timber
@RequiresApi(30)
value class WifiClient(val inner: Parcelable) {
companion object {
private val clazz by lazy { Class.forName("android.net.wifi.WifiClient") }
val clazz by lazy { Class.forName("android.net.wifi.WifiClient") }
private val getMacAddress by lazy { clazz.getDeclaredMethod("getMacAddress") }
@get:RequiresApi(31)
private val getApInstanceIdentifier by lazy @TargetApi(31) { UnblockCentral.getApInstanceIdentifier(clazz) }

View File

@@ -1,15 +1,18 @@
package be.mygod.vpnhotspot.net.wifi
import android.annotation.SuppressLint
import android.net.MacAddress
import android.net.wifi.ScanResult
import android.net.wifi.WpsInfo
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pInfo
import android.net.wifi.p2p.WifiP2pManager
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.util.callSuper
import be.mygod.vpnhotspot.util.matches
import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@@ -53,6 +56,15 @@ object WifiP2pManagerHelper {
return result.future.await()
}
@SuppressLint("MissingPermission") // this method will fail correctly if permission is missing
@RequiresApi(33)
suspend fun WifiP2pManager.setVendorElements(c: WifiP2pManager.Channel,
ve: List<ScanResult.InformationElement>): Int? {
val result = ResultListener()
setVendorElements(c, ve, result)
return result.future.await()
}
/**
* Available since Android 4.3.
*
@@ -98,9 +110,8 @@ object WifiP2pManagerHelper {
private val interfacePersistentGroupInfoListener by lazy {
Class.forName("android.net.wifi.p2p.WifiP2pManager\$PersistentGroupInfoListener")
}
private val getGroupList by lazy {
Class.forName("android.net.wifi.p2p.WifiP2pGroupList").getDeclaredMethod("getGroupList")
}
private val classWifiP2pGroupList by lazy { Class.forName("android.net.wifi.p2p.WifiP2pGroupList") }
private val getGroupList by lazy { classWifiP2pGroupList.getDeclaredMethod("getGroupList") }
private val requestPersistentGroupInfo by lazy {
WifiP2pManager::class.java.getDeclaredMethod("requestPersistentGroupInfo",
WifiP2pManager.Channel::class.java, interfacePersistentGroupInfoListener)
@@ -116,9 +127,8 @@ object WifiP2pManagerHelper {
val result = CompletableDeferred<Collection<WifiP2pGroup>>()
requestPersistentGroupInfo(this, c, Proxy.newProxyInstance(interfacePersistentGroupInfoListener.classLoader,
arrayOf(interfacePersistentGroupInfoListener), object : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? = when (method.name) {
"onPersistentGroupInfoAvailable" -> {
if (args?.size != 1) Timber.w(IllegalArgumentException("Unexpected args: $args"))
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? = when {
method.matches("onPersistentGroupInfoAvailable", classWifiP2pGroupList) -> {
@Suppress("UNCHECKED_CAST")
result.complete(getGroupList(args!![0]) as Collection<WifiP2pGroup>)
}
@@ -128,14 +138,22 @@ object WifiP2pManagerHelper {
return result.await()
}
@SuppressLint("MissingPermission")
suspend fun WifiP2pManager.requestConnectionInfo(c: WifiP2pManager.Channel) =
CompletableDeferred<WifiP2pInfo?>().apply { requestConnectionInfo(c) { complete(it) } }.await()
@SuppressLint("MissingPermission") // missing permission simply leads to null result
@RequiresApi(29)
suspend fun WifiP2pManager.requestDeviceAddress(c: WifiP2pManager.Channel): MacAddressCompat? {
suspend fun WifiP2pManager.requestDeviceAddress(c: WifiP2pManager.Channel): MacAddress? {
val future = CompletableDeferred<String?>()
requestDeviceInfo(c) { future.complete(it?.deviceAddress) }
return future.await()?.let {
val address = if (it.isEmpty()) null else MacAddressCompat.fromString(it)
val address = if (it.isEmpty()) null else MacAddress.fromString(it)
if (address == MacAddressCompat.ANY_ADDRESS) null else address
}
}
@SuppressLint("MissingPermission") // missing permission simply leads to null result
suspend fun WifiP2pManager.requestGroupInfo(c: WifiP2pManager.Channel) =
CompletableDeferred<WifiP2pGroup?>().apply { requestGroupInfo(c) { complete(it) } }.await()
@RequiresApi(29)
suspend fun WifiP2pManager.requestP2pState(c: WifiP2pManager.Channel) =
CompletableDeferred<Int>().apply { requestP2pState(c) { complete(it) } }.await()
}

View File

@@ -0,0 +1,67 @@
package be.mygod.vpnhotspot.net.wifi
import android.net.wifi.WifiSsid
import android.os.Parcelable
import androidx.annotation.RequiresApi
import kotlinx.parcelize.Parcelize
import org.jetbrains.annotations.Contract
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
@Parcelize
data class WifiSsidCompat(val bytes: ByteArray) : Parcelable {
companion object {
private val hexTester = Regex("^(?:[0-9a-f]{2})*$", RegexOption.IGNORE_CASE)
private val qrSanitizer = Regex("([\\\\\":;,])")
fun fromHex(hex: CharSequence?) = hex?.run {
require(length % 2 == 0) { "Input should be hex: $hex" }
WifiSsidCompat((0 until length / 2).map {
Integer.parseInt(substring(it * 2, it * 2 + 2), 16).toByte()
}.toByteArray())
}
@Contract("null -> null; !null -> !null")
fun fromUtf8Text(text: CharSequence?) = text?.toString()?.toByteArray()?.let { WifiSsidCompat(it) }
fun toMeCard(text: String) = qrSanitizer.replace(text) { "\\${it.groupValues[1]}" }
@RequiresApi(33)
fun WifiSsid.toCompat() = WifiSsidCompat(bytes)
}
init {
require(bytes.size <= 32) { "${bytes.size} > 32" }
}
@RequiresApi(31)
fun toPlatform() = WifiSsid.fromBytes(bytes)
fun decode(charset: Charset = Charsets.UTF_8) = CharBuffer.allocate(32).run {
val result = charset.newDecoder().apply {
onMalformedInput(CodingErrorAction.REPORT)
onUnmappableCharacter(CodingErrorAction.REPORT)
}.decode(ByteBuffer.wrap(bytes), this, true)
if (result.isError) null else flip().toString()
}
val hex get() = bytes.joinToString("") { "%02x".format(it.toUByte().toInt()) }
fun toMeCard(): String {
val utf8 = decode() ?: return hex
return if (hexTester.matches(utf8)) "\"$utf8\"" else toMeCard(utf8)
}
override fun toString() = String(bytes)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WifiSsidCompat
if (!bytes.contentEquals(other.bytes)) return false
return true
}
override fun hashCode() = bytes.contentHashCode()
}