diff --git a/README.md b/README.md index 07e1c128..e00da41a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,10 @@ Default settings are picked to suit general use cases and maximize compatibility You might be required to turn this mode off if you want to use short SSID (at most 8 bytes long). Unsafe mode might not work for your device, and there is a small chance you will soft brick your device (recoverable). See [#153](https://github.com/Mygod/VPNHotspot/issues/153) for more information. +* Use system configuration for temporary hotspot: (Android 11 or newer) + Attempt to start a temporary hotspot using system Wi-Fi hotspot configuration. + This feature is most likely only functional on Android 12 or newer. + Enabling this switch will also prevent other apps from using the [local-only hotspot](https://developer.android.com/guide/topics/connectivity/localonlyhotspot) functionality. * Network status monitor mode: This option controls how the app monitors connected devices as well as interface changes (when custom upstream is used). Requires restarting the app to take effects. (best way is to go to app info and force stop) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt index 2184ced6..7cc513b6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt @@ -9,21 +9,17 @@ import be.mygod.vpnhotspot.room.AppDatabase import be.mygod.vpnhotspot.util.connectCancellable import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.json.JSONException import org.json.JSONObject import timber.log.Timber -import java.io.File -import java.io.FileNotFoundException import java.io.IOException -import java.net.HttpCookie -import java.util.Scanner -import java.util.regex.Pattern +import java.math.BigInteger +import java.security.MessageDigest /** * This class generates a default nickname for new clients. @@ -37,43 +33,7 @@ object MacLookup { override fun getLocalizedMessage() = formatMessage(app) } - private object SessionManager { - private const val CACHE_FILENAME = "maclookup_sessioncache" - private const val COOKIE_SESSION = "mac_address_vendor_lookup_session" - private val csrfPattern = Pattern.compile("? - get() = try { - File(app.deviceStorage.cacheDir, CACHE_FILENAME).readText().split('\n', limit = 2) - } catch (_: FileNotFoundException) { - null - } - set(value) = File(app.deviceStorage.cacheDir, CACHE_FILENAME).run { - if (value != null) writeText(value.joinToString("\n")) else if (!delete()) writeText("") - } - private val mutex = Mutex() - - private suspend fun refreshSessionCache() = connectCancellable("https://macaddress.io/api") { conn -> - val cookies = conn.headerFields["set-cookie"] ?: throw IOException("Missing cookies") - var mavls: HttpCookie? = null - for (header in cookies) for (cookie in HttpCookie.parse(header)) { - if (cookie.name == COOKIE_SESSION) mavls = cookie - } - if (mavls == null) throw IOException("Missing set-cookie $COOKIE_SESSION") - val token = conn.inputStream.use { Scanner(it).findWithinHorizon(csrfPattern, 0) } - ?: throw IOException("Missing csrf-token") - listOf(mavls.toString(), csrfPattern.matcher(token).run { - check(matches()) - group(1)!! - }).also { sessionCache = it } - } - - suspend fun obtain(forceNew: Boolean): Pair = mutex.withLock { - val sessionCache = (if (forceNew) null else sessionCache) ?: refreshSessionCache() - HttpCookie.parse(sessionCache[0]).single() to sessionCache[1] - } - } - + private val sha1 = MessageDigest.getInstance("SHA-1") private val macLookupBusy = mutableMapOf() // http://en.wikipedia.org/wiki/ISO_3166-1 private val countryCodeRegex = "(?:^|[^A-Z])([A-Z]{2})[\\s\\d]*$".toRegex() @@ -84,31 +44,29 @@ object MacLookup { @MainThread fun perform(mac: MacAddress, explicit: Boolean = false) { abort(mac) - macLookupBusy[mac] = GlobalScope.launch(Dispatchers.IO) { + macLookupBusy[mac] = GlobalScope.launch(Dispatchers.Unconfined, CoroutineStart.UNDISPATCHED) { + var response: String? = null try { - var response: String? = null - for (tries in 0 until 5) { - val (cookie, csrf) = SessionManager.obtain(tries > 0) - response = connectCancellable("https://macaddress.io/mac-address-lookup") { conn -> - conn.requestMethod = "POST" - conn.setRequestProperty("content-type", "application/json") - conn.setRequestProperty("cookie", "${cookie.name}=${cookie.value}") - conn.setRequestProperty("x-csrf-token", csrf) - conn.outputStream.writer().use { it.write("{\"macAddress\":\"$mac\",\"not-web-search\":true}") } - when (val responseCode = conn.responseCode) { - 200 -> conn.inputStream.bufferedReader().readText() - 419 -> null - else -> throw IOException("Unhandled response code $responseCode") - } + response = connectCancellable("https://api.maclookup.app/v2/macs/$mac") { conn -> +// conn.setRequestProperty("X-App-Id", "net.mobizme.macaddress") +// conn.setRequestProperty("X-App-Version", "2.0.11") +// conn.setRequestProperty("X-App-Version-Code", "111") + val epoch = System.currentTimeMillis() + conn.setRequestProperty("X-App-Epoch", epoch.toString()) + conn.setRequestProperty("X-App-Sign", "%032x".format(BigInteger(1, + sha1.digest("aBA6AEkfg8cbHlWrBDYX_${mac}_$epoch".toByteArray())))) + when (val responseCode = conn.responseCode) { + 200 -> conn.inputStream.bufferedReader().readText() + 400, 401, 429 -> throw UnexpectedError(mac, conn.inputStream.bufferedReader().readText()) + else -> throw UnexpectedError(mac, "Unhandled response code $responseCode: " + + conn.inputStream.bufferedReader().readText()) } - if (response != null) break } - if (response == null) throw IOException("Session creation failure") val obj = JSONObject(response) - val result = if (obj.getJSONObject("blockDetails").getBoolean("blockFound")) { - val vendor = obj.getJSONObject("vendorDetails") - val company = vendor.getString("companyName") - val match = extractCountry(mac, response, vendor) + if (!obj.getBoolean("success")) throw UnexpectedError(mac, response) + val result = if (obj.getBoolean("found")) { + val company = obj.getString("company") + val match = extractCountry(mac, response, obj) if (match != null) { String(match.groupValues[1].flatMap { listOf('\uD83C', it + 0xDDA5) }.toCharArray()) + ' ' + company @@ -120,15 +78,19 @@ object MacLookup { } } catch (_: CancellationException) { } catch (e: Throwable) { - Timber.w(e) + when (e) { + is UnexpectedError -> Timber.w(e) + is IOException -> Timber.d(e) + else -> Timber.w(UnexpectedError(mac, "Got response: $response").initCause(e)) + } if (explicit) SmartSnackbar.make(e).show() } } } private fun extractCountry(mac: MacAddress, response: String, obj: JSONObject): MatchResult? { - countryCodeRegex.matchEntire(obj.optString("countryCode"))?.also { return it } - val address = obj.optString("companyAddress") + countryCodeRegex.matchEntire(obj.optString("country"))?.also { return it } + val address = obj.optString("address") if (address.isBlank()) return null countryCodeRegex.find(address)?.also { return it } Timber.w(UnexpectedError(mac, response)) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt index f65c94b4..0a352762 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt @@ -3,6 +3,7 @@ package be.mygod.vpnhotspot.net.wifi import android.annotation.TargetApi import android.content.Intent import android.content.pm.PackageManager +import android.content.pm.ResolveInfo import android.content.res.Resources import android.net.wifi.SoftApConfiguration import android.net.wifi.WifiManager @@ -27,11 +28,24 @@ object WifiApManager { @RequiresApi(30) const val RESOURCES_PACKAGE = "com.android.wifi.resources" /** - * Based on: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/000ad45/service/java/com/android/server/wifi/WifiContext.java#66 + * Based on: https://cs.android.com/android/platform/superproject/+/master:packages/modules/Wifi/framework/java/android/net/wifi/WifiContext.java;l=66;drc=5ca657189aac546af0aafaba11bbc9c5d889eab3 */ @get:RequiresApi(30) - val resolvedActivity get() = app.packageManager.queryIntentActivities(Intent(ACTION_RESOURCES_APK), - PackageManager.MATCH_SYSTEM_ONLY).single() + val resolvedActivity: ResolveInfo get() { + val list = app.packageManager.queryIntentActivities(Intent(ACTION_RESOURCES_APK), + PackageManager.MATCH_SYSTEM_ONLY) + require(list.isNotEmpty()) { "Missing $ACTION_RESOURCES_APK" } + if (list.size > 1) { + list.singleOrNull { + it.activityInfo.applicationInfo.sourceDir.startsWith("/apex/com.android.wifi") + }?.let { return it } + Timber.w(Exception("Found > 1 apk: " + list.joinToString { + val info = it.activityInfo.applicationInfo + "${info.packageName} (${info.sourceDir})" + })) + } + return list[0] + } private const val CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED = "config_wifi_p2p_mac_randomization_supported" val p2pMacRandomizationSupported get() = try {