From 665c4724823e1396c3d0503fb8b002ff149d9432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=80=9A=E6=B4=B2?= Date: Sat, 19 Sep 2026 00:39:25 +0800 Subject: [PATCH] Wifi (Android): rewrites impl via `/dev/binder` --- CHANGELOG.md | 4 + CMakeLists.txt | 1 + src/common/android/dex.c | 358 +++++++++++++++ src/common/android/dex.h | 30 ++ src/detection/wifi/wifi_android.c | 714 ++++++++++++++++++++++++++++-- 5 files changed, 1072 insertions(+), 35 deletions(-) create mode 100644 src/common/android/dex.c create mode 100644 src/common/android/dex.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e07dbdf..45e6ebf66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,10 @@ Features: * The displays are now read from the display service instead of a vendor property that only some Xiaomi devices set. The preferred mode, the physical size, the rotation, the manufacture date and the display id are now reported as well. * This needs Android 13 (API 33). On Android 12 and older only that vendor property is available to an app, and a device that does not set it reports no display. * The refresh rate is now the rate of the active display mode, and the HDR capability is read from the display itself, for every display rather than only for the built-in one. +* Improved WiFi detection on Android (Wifi, Android) + * The connection details are now read from the WiFi service over `/dev/binder` instead of `termux-api WifiConnectionInfo`, so the Termux:API app is no longer required and no subprocess is spawned. + * The interface name and its state, the connection state and the Wi-Fi standard are now reported as well. + * This needs Android 11 (API 30), the release that moved the Wi-Fi framework into an APEX. There is no fallback, so Android 10 and older report an error instead. * Improved COSMIC detection (DE / WM, Linux) * The version is now read from the `COSMIC_VERSION` environment variable when it is set. * Improved accuracy and performance of process name detection in the Top module. (Top, macOS) diff --git a/CMakeLists.txt b/CMakeLists.txt index 018c7d2ba..bf2175145 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -616,6 +616,7 @@ if(LINUX) elseif(ANDROID) list(APPEND LIBFASTFETCH_SRC src/common/android/binder.c + src/common/android/dex.c src/common/impl/dbus.c src/common/impl/io_unix.c src/common/impl/netif_linux.c diff --git a/src/common/android/dex.c b/src/common/android/dex.c new file mode 100644 index 000000000..7fdb90458 --- /dev/null +++ b/src/common/android/dex.c @@ -0,0 +1,358 @@ +#include "common/android/dex.h" + +#include +#include +#include +#include + +#ifdef FF_HAVE_ZLIB + #include "common/library.h" + #include +#endif + +// Offsets into the dex header, from https://source.android.com/docs/core/runtime/dex-format. +#define FF_DEX_ENDIAN_TAG 0x12345678u +#define FF_DEX_HEADER_SIZE 0x70 +#define FF_DEX_OFF_FILE_SIZE 0x20 +#define FF_DEX_OFF_ENDIAN_TAG 0x28 +#define FF_DEX_OFF_STRING_IDS 0x3C +#define FF_DEX_OFF_TYPE_IDS_SIZE 0x40 +#define FF_DEX_OFF_TYPE_IDS 0x44 +#define FF_DEX_OFF_FIELD_IDS 0x54 +#define FF_DEX_OFF_CLASS_DEFS_SIZE 0x60 +#define FF_DEX_OFF_CLASS_DEFS 0x64 + +// class_def_item: [u32 class_idx][u32 access_flags][u32 superclass_idx][u32 interfaces_off] +// [u32 source_file_idx][u32 annotations_off][u32 class_data_off][u32 static_values_off] +#define FF_DEX_CLASS_DEF_SIZE 32 +#define FF_DEX_OFF_CLASS_DEF_DATA 24 +#define FF_DEX_OFF_CLASS_DEF_STATIC_VALUES 28 + +// field_id_item: [u16 class_idx][u16 type_idx][u32 name_idx] +#define FF_DEX_FIELD_ID_SIZE 8 +#define FF_DEX_OFF_FIELD_ID_NAME 4 + +// The zip local file header, from APPNOTE.TXT 4.3.7. The central directory would carry the same +// fields, but the local header sits right in front of the data, so one walk over the file finds +// both the bounds and the payload. +#define FF_ZIP_LOCAL_HEADER_SIZE 30 +#define FF_ZIP_OFF_METHOD 8 +#define FF_ZIP_OFF_COMPRESSED_SIZE 18 +#define FF_ZIP_OFF_UNCOMPRESSED_SIZE 22 +#define FF_ZIP_OFF_NAME_LENGTH 26 +#define FF_ZIP_OFF_EXTRA_LENGTH 28 +#define FF_ZIP_METHOD_STORED 0 +#define FF_ZIP_METHOD_DEFLATED 8 + +#define FF_DEX_ENTRY "classes.dex" +#define FF_DEX_MAGIC "dex\n" + +static uint16_t dexU16(const uint8_t* p) { + return (uint16_t) ((uint16_t) p[0] | (uint16_t) ((uint16_t) p[1] << 8)); +} + +static uint32_t dexU32(const uint8_t* p) { + return (uint32_t) p[0] | ((uint32_t) p[1] << 8) | ((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24); +} + +// LEB128, at most five bytes for the 32-bit values a dex stores this way. +static uint32_t dexUleb128(const uint8_t** p) { + uint32_t value = 0; + for (int shift = 0; shift <= 28; shift += 7) { + const uint8_t byte = *(*p)++; + value |= (uint32_t) (byte & 0x7f) << shift; + if ((byte & 0x80) == 0) { + break; + } + } + return value; +} + +// encoded_value: one header byte holding ((size - 1) << 5) | type, then that many payload bytes. A +// `static final int` constant is written as VALUE_INT, whose payload is a sign-extended +// little-endian integer. Reading through 64 bits keeps every shift in range even if the size field +// is nonsense; the caller's length check is what rejects such a dex. +static int32_t dexEncodedInt(const uint8_t** p) { + const uint8_t header = *(*p)++; + const uint32_t size = (uint32_t) (header >> 5) + 1; + uint64_t value = 0; + for (uint32_t i = 0; i < size && i < 8; ++i) { + value |= (uint64_t) (*(*p)++) << (i * 8); + } + const uint32_t bits = size * 8; + if (bits < 64 && (value & (1ull << (bits - 1))) != 0) { + value |= ~((1ull << bits) - 1); + } + return (int32_t) (uint32_t) value; +} + +// string_data_item: a uleb128 length in UTF-16 code units, then MUTF-8 bytes and a NUL terminator. +// The descriptors and field names looked up here are ASCII, where MUTF-8 and UTF-8 agree, so the +// bytes can be used as they are. +static const char* dexString(const uint8_t* dex, const uint8_t* end, uint32_t index) { + const uint8_t* ids = dex + dexU32(dex + FF_DEX_OFF_STRING_IDS); + if (ids < dex || (size_t) (end - ids) < (size_t) index * 4 + 4) { + return nullptr; + } + const uint8_t* p = dex + dexU32(ids + (size_t) index * 4); + if (p < dex || p >= end) { + return nullptr; + } + (void) dexUleb128(&p); + if (p >= end) { + return nullptr; + } + return memchr(p, '\0', (size_t) (end - p)) != nullptr ? (const char*) p : nullptr; +} + +// Walks the class's static field list and the class's static value array in step, and returns the +// value paired with `fieldName`. Both lists are ordered by field index and cover exactly the same +// fields -- when they do not, the pairing is not trustworthy and nothing is returned. +static const char* dexStaticInt(const uint8_t* dex, size_t size, const char* classDescriptor, const char* fieldName, int32_t* result) { + const uint8_t* end = dex + size; + + if (size < FF_DEX_HEADER_SIZE || dexU32(dex + FF_DEX_OFF_ENDIAN_TAG) != FF_DEX_ENDIAN_TAG) { + return "Not a dex file"; + } + const uint32_t fileSize = dexU32(dex + FF_DEX_OFF_FILE_SIZE); + if (fileSize > size || fileSize < FF_DEX_HEADER_SIZE) { + return "The dex file size is out of range"; + } + end = dex + fileSize; + + // The class's type index, so that the class_def_item and the field ids can be filtered by class. + const uint8_t* types = dex + dexU32(dex + FF_DEX_OFF_TYPE_IDS); + const uint32_t typeCount = dexU32(dex + FF_DEX_OFF_TYPE_IDS_SIZE); + if (types < dex || (size_t) (end - types) < (size_t) typeCount * 4) { + return "The dex type table is out of range"; + } + uint32_t typeIndex = UINT32_MAX; + for (uint32_t i = 0; i < typeCount; ++i) { + const char* descriptor = dexString(dex, end, dexU32(types + (size_t) i * 4)); + if (descriptor != nullptr && strcmp(descriptor, classDescriptor) == 0) { + typeIndex = i; + break; + } + } + if (typeIndex == UINT32_MAX) { + return "The class is not in the dex"; + } + + const uint8_t* classes = dex + dexU32(dex + FF_DEX_OFF_CLASS_DEFS); + const uint32_t classCount = dexU32(dex + FF_DEX_OFF_CLASS_DEFS_SIZE); + if (classes < dex || (size_t) (end - classes) < (size_t) classCount * FF_DEX_CLASS_DEF_SIZE) { + return "The dex class table is out of range"; + } + + for (uint32_t i = 0; i < classCount; ++i) { + const uint8_t* classDef = classes + (size_t) i * FF_DEX_CLASS_DEF_SIZE; + if (dexU32(classDef) != typeIndex) { + continue; + } + + const uint8_t* fields = dex + dexU32(classDef + FF_DEX_OFF_CLASS_DEF_DATA); + const uint8_t* values = dex + dexU32(classDef + FF_DEX_OFF_CLASS_DEF_STATIC_VALUES); + if (fields < dex || fields >= end || values < dex || values >= end) { + return "The dex class data is out of range"; + } + + const uint32_t staticFields = dexUleb128(&fields); + (void) dexUleb128(&fields); // instance_fields_size + (void) dexUleb128(&fields); // direct_methods_size + (void) dexUleb128(&fields); // virtual_methods_size + const uint32_t staticValues = dexUleb128(&values); + if (staticFields != staticValues) { + return "The dex static fields and values do not pair up"; + } + + const uint8_t* fieldIds = dex + dexU32(dex + FF_DEX_OFF_FIELD_IDS); + uint32_t fieldIndex = 0; + for (uint32_t j = 0; j < staticFields; ++j) { + if (fields >= end) { + return "The dex static field list is truncated"; + } + fieldIndex += dexUleb128(&fields); // field_idx_diff + (void) dexUleb128(&fields); // access_flags + const int32_t value = dexEncodedInt(&values); + + if ((size_t) (end - fieldIds) < ((size_t) fieldIndex + 1) * FF_DEX_FIELD_ID_SIZE) { + return "The dex field table is out of range"; + } + const char* name = dexString(dex, end, dexU32(fieldIds + (size_t) fieldIndex * FF_DEX_FIELD_ID_SIZE + FF_DEX_OFF_FIELD_ID_NAME)); + if (name != nullptr && strcmp(name, fieldName) == 0) { + *result = value; + return nullptr; + } + } + return "The field is not a static field of the class"; + } + return "The class has no class_def_item"; +} + +// --------------------------------------------------------------------------------------------- +// Locating classes.dex +// --------------------------------------------------------------------------------------------- + +typedef struct FFDexMapping { + uint8_t* mapped; // the jar, as mapped; nullptr once released + size_t mappedSize; + const uint8_t* data; // the dex bytes: inside `mapped`, or `inflated` + size_t size; + uint8_t* inflated; // owned; only set when the entry had to be decompressed +} FFDexMapping; + +static void wrapDexMapping(FFDexMapping* mapping) { + assert(mapping); + free(mapping->inflated); + if (mapping->mapped != nullptr) { + munmap(mapping->mapped, mapping->mappedSize); + } +} + +// The local file header of `classes.dex` carries both the payload bounds and its compression, so a +// single walk over the jar is enough to find it either way. AOSP builds framework jars with the +// entry STORED so that ART can map it, which is the common case and needs no decompression. +static const char* findDexEntry(const uint8_t* jar, size_t jarSize, const uint8_t** data, size_t* dataSize, uint32_t* uncompressedSize, uint16_t* method) { + for (size_t i = 0; i + FF_ZIP_LOCAL_HEADER_SIZE <= jarSize; ++i) { + if (memcmp(jar + i, "PK\x03\x04", 4) != 0) { + continue; + } + const uint16_t nameLength = dexU16(jar + i + FF_ZIP_OFF_NAME_LENGTH); + const uint16_t extraLength = dexU16(jar + i + FF_ZIP_OFF_EXTRA_LENGTH); + if (nameLength != sizeof(FF_DEX_ENTRY) - 1) { + continue; + } + const size_t headerSize = FF_ZIP_LOCAL_HEADER_SIZE + (size_t) nameLength + (size_t) extraLength; + if (headerSize > jarSize - i) { + continue; + } + if (memcmp(jar + i + FF_ZIP_LOCAL_HEADER_SIZE, FF_DEX_ENTRY, sizeof(FF_DEX_ENTRY) - 1) != 0) { + continue; + } + + const uint32_t compressedSize = dexU32(jar + i + FF_ZIP_OFF_COMPRESSED_SIZE); + if (compressedSize == 0 || compressedSize > jarSize - i - headerSize) { + // A zero size means a data descriptor follows the payload instead of the header; the + // magic scan in the caller still covers that case as long as the entry is STORED. + continue; + } + *method = dexU16(jar + i + FF_ZIP_OFF_METHOD); + *uncompressedSize = dexU32(jar + i + FF_ZIP_OFF_UNCOMPRESSED_SIZE); + *data = jar + i + headerSize; + *dataSize = compressedSize; + return nullptr; + } + return "The jar has no usable classes.dex entry"; +} + +// Fallback for a jar whose entry is STORED but whose header has no sizes: the dex magic is then a +// literal run of bytes in the file, and the header that follows it validates or it does not. +static const uint8_t* findDexMagic(const uint8_t* jar, size_t jarSize, size_t* dexSize) { + for (size_t i = 0; i + FF_DEX_HEADER_SIZE <= jarSize; ++i) { + if (memcmp(jar + i, FF_DEX_MAGIC, sizeof(FF_DEX_MAGIC) - 1) != 0) { + continue; + } + const uint8_t* dex = jar + i; + const uint32_t fileSize = dexU32(dex + FF_DEX_OFF_FILE_SIZE); + if (dexU32(dex + FF_DEX_OFF_ENDIAN_TAG) != FF_DEX_ENDIAN_TAG) { + continue; + } + if (fileSize < FF_DEX_HEADER_SIZE || fileSize > jarSize - i) { + continue; + } + *dexSize = fileSize; + return dex; + } + return nullptr; +} + +#ifdef FF_HAVE_ZLIB +static const char* inflateDex(const uint8_t* data, size_t dataSize, uint32_t uncompressedSize, uint8_t** out) { + FF_LIBRARY_LOAD(zlib, "dlopen(libz) failed", "libz" FF_LIBRARY_EXTENSION, 2) + FF_LIBRARY_LOAD_SYMBOL(zlib, inflateInit2_, "dlsym(inflateInit2_) failed") + FF_LIBRARY_LOAD_SYMBOL(zlib, inflate, "dlsym(inflate) failed") + FF_LIBRARY_LOAD_SYMBOL(zlib, inflateEnd, "dlsym(inflateEnd) failed") + + uint8_t* buffer = malloc(uncompressedSize); + if (buffer == nullptr) { + return "malloc failed"; + } + + // `uncompress` is not usable here: a zip entry holds a raw deflate stream, without the two byte + // zlib header that entry point insists on. A negative window size tells inflate to skip the + // header, which is what the zip format expects. + z_stream stream = {}; + stream.next_in = (Bytef*) data; + stream.avail_in = (uInt) dataSize; + stream.next_out = buffer; + stream.avail_out = (uInt) uncompressedSize; + + if (ffinflateInit2_(&stream, -MAX_WBITS, ZLIB_VERSION, (int) sizeof(z_stream)) != Z_OK) { + free(buffer); + return "inflateInit2 failed"; + } + const int status = ffinflate(&stream, Z_FINISH); + ffinflateEnd(&stream); + if (status != Z_STREAM_END || stream.total_out != (uLong) uncompressedSize) { + free(buffer); + return "Inflating classes.dex failed"; + } + + *out = buffer; + return nullptr; +} +#endif + +const char* ffDexStaticInt(const char* jarPath, const char* classDescriptor, const char* fieldName, int32_t* result) { + [[gnu::cleanup(wrapDexMapping)]] FFDexMapping mapping = {}; + + const int fd = open(jarPath, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return "open(jar) failed"; + } + + struct stat st = {}; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { + close(fd); + return "fstat(jar) failed"; + } + mapping.mappedSize = (size_t) st.st_size; + mapping.mapped = mmap(nullptr, mapping.mappedSize, PROT_READ, MAP_PRIVATE, fd, 0); + // The mapping outlives the descriptor, so the descriptor can go either way from here. + close(fd); + if (mapping.mapped == MAP_FAILED) { + mapping.mapped = nullptr; + return "mmap(jar) failed"; + } + + const uint8_t* data = nullptr; + size_t dataSize = 0; + uint32_t uncompressedSize = 0; + uint16_t method = FF_ZIP_METHOD_STORED; + + if (findDexEntry(mapping.mapped, mapping.mappedSize, &data, &dataSize, &uncompressedSize, &method) != nullptr) { + data = findDexMagic(mapping.mapped, mapping.mappedSize, &dataSize); + if (data == nullptr) { + return "No dex in the jar"; + } + } else if (method == FF_ZIP_METHOD_STORED) { + dataSize = uncompressedSize; + } else if (method == FF_ZIP_METHOD_DEFLATED) { + #ifdef FF_HAVE_ZLIB + const char* error = inflateDex(data, dataSize, uncompressedSize, &mapping.inflated); + if (error != nullptr) { + return error; + } + data = mapping.inflated; + dataSize = uncompressedSize; + #else + return "The jar deflates classes.dex and fastfetch was built without zlib"; + #endif + } else { + return "classes.dex uses an unsupported compression method"; + } + + mapping.data = data; + mapping.size = dataSize; + return dexStaticInt(mapping.data, mapping.size, classDescriptor, fieldName, result); +} diff --git a/src/common/android/dex.h b/src/common/android/dex.h new file mode 100644 index 000000000..34197506a --- /dev/null +++ b/src/common/android/dex.h @@ -0,0 +1,30 @@ +#pragma once + +// AIDL gives every method of an interface a transaction code at build time: +// +// static final int TRANSACTION_ = IBinder.FIRST_CALL_TRANSACTION + ; +// +// so the number is a property of the `.aidl` of the release that built the device's jar, not of any +// public header. Counting the methods in an AOSP checkout only describes that checkout, and it goes +// wrong the moment a release or a vendor fork inserts a method ahead of the one being looked for: +// `IWifiManager.getConnectionInfo` is 29 on Android 11 and 41 on Android 16. There is no negotiation +// and no discovery -- a wrong code reaches a different method, or none. +// +// Being a compile-time constant, the value lands in the `static_values` array of the `X$Stub` class +// in the jar's dex, paired position by position with that class's static field list. Reading the two +// together gives the mapping of the build that is actually on the device, on any release, with +// nothing to keep in sync. +// +// `libdexfile.so` cannot do this for us. It is dlopen-able from an app UID through the ART apex, but +// it is not in the NDK and its only C ABI -- the `ADexFile_*` family -- covers methods, not fields; +// the field and class-data APIs are C++ with no ABI guarantee. +// +// `ffReadFileBuffer` would work but pulls the whole jar through the heap for one integer. The jar is +// mapped instead, and only the pages actually read are faulted in. + +#include "fastfetch.h" + +// Resolves the int value of `.` out of the `classes.dex` of `jarPath`. +// `classDescriptor` is the dex type descriptor, e.g. "Landroid/net/wifi/IWifiManager$Stub;". +// Returns nullptr on success, a static message otherwise. +[[gnu::nonnull(1, 2, 3, 4), nodiscard]] const char* ffDexStaticInt(const char* jarPath, const char* classDescriptor, const char* fieldName, int32_t* result); diff --git a/src/detection/wifi/wifi_android.c b/src/detection/wifi/wifi_android.c index 0fd5b3a9d..706469bb7 100644 --- a/src/detection/wifi/wifi_android.c +++ b/src/detection/wifi/wifi_android.c @@ -1,33 +1,663 @@ #include "wifi.h" -#include "common/processing.h" -#include "common/properties.h" +#include "common/android/api.h" +#include "common/android/binder.h" +#include "common/android/dex.h" +#include "common/debug.h" -#define FF_TERMUX_API_PATH FASTFETCH_TARGET_DIR_ROOT "/libexec/termux-api" -#define FF_TERMUX_API_PARAM "WifiConnectionInfo" +#include +#include +#include -static inline void wrapYyjsonFree(yyjson_doc** doc) { - assert(doc); - if (*doc) { - yyjson_doc_free(*doc); +// Android gives an app two ways to see the current connection, and both are weaker than the desktop +// ones. `/system/bin/cmd wifi status` prints everything fastfetch wants, but it does not run in the +// calling process: `cmd` hands the arguments to the service over binder and the command body runs +// inside system_server, where it reads a locally built WifiInfo. An app can therefore not ask for +// it -- the call comes back as a silent exit status 255 with nothing on either stream, which is +// what makes it look like it worked. `dumpsys wifi` needs android.permission.DUMP, and +// /proc/net/wireless, /proc/net/dev and /sys/class/net/wlan0 are all EACCES. That leaves +// `IWifiManager.getConnectionInfo`, which needs only ACCESS_WIFI_STATE -- a normal permission, +// granted without asking. There is no Termux:API fallback: it returned less than this does, and +// needed a separate app installed. +// +// The transaction code is read out of the device's own jar rather than carried as a table, so the +// module is not tied to a list of releases. It does need the jar to exist, and Android 11 is the +// release that moved the Wi-Fi framework into an APEX -- before that the class sat in framework.jar, +// which is not read here. So Android 11 (API 30) is the floor, and an older release reports an error +// rather than a guess. +// +// The reply layout itself has been checked against the AOSP source as far back as Android 9, which +// is why nothing about it is release-gated: the head grew a link speed in Android 10 and a Wi-Fi +// standard in Android 11, and both are found by shape. +// +// Whether the radio is on is a second call, `IWifiManager.getWifiEnabledState`, which takes no +// arguments and answers with a `WifiManager.WIFI_STATE_*`. It is needed because the interface +// cannot answer for itself: bionic's `getifaddrs` lists only the interfaces that hold an address, +// so the flags that would say whether `wlan0` is up leave the list exactly when there is no +// association -- and nothing else in the reply separates a Wi-Fi that is off from one that is on +// with nothing behind it. Both answer with an empty `WifiInfo`, the same at every offset, down to +// the `02:00:00:00:00:00` placeholder it writes in place of a BSSID. The call is made only when the +// interface was not in the list, so a connected device -- where `IFF_UP` is both closer to hand and +// more accurate -- pays nothing for it. +// +// Three things about the call are positional rather than negotiated, and all three were measured +// on a vivo V2505A (Android 16) and a Redmi 9A (Android 11): +// +// * The transaction code is a build-time constant of the `.aidl` (`getConnectionInfo` is 29 on +// Android 11 and 41 on Android 16) -- see common/android/dex.h. +// * The reply layout drifts, and not only by release. Android 12 dropped the duplicate length word +// that Android 11 and 10 wrote in front of the SSID octets, and the vivo's Android 16 build +// fills an int between the transmit and the receive speed that the Redmi's Android 11 build +// leaves out -- while AOSP's own Android 16 writes the head exactly the Android 11 way, so that +// one int is a vendor addition rather than a release difference. Frequency is the only channel +// centre in the head, so it is located by value, with the whole result validated before it is +// used. A wrong guess shows up as a rejected layout, not as a plausible wrong number. +// * The tail of the parcel, which holds the Wi-Fi standard, is not laid out the same way by every +// vendor: the Android 11 build on the Redmi carries three words there that AOSP's own Android 11 +// does not. It is located by shape for that reason. +// +// BSSID and MAC are the one place the binder route beats `cmd wifi status`: MAC addresses are +// redacted for a caller targeting a recent SDK, and the shell targets the current one, so `cmd wifi +// status` prints 24:**:**:**:70:2c where this reads 24:a4:87:3c:70:2c. +// +// What is deliberately left empty: +// +// * `security`. `WifiInfo` carries no security type on Android 11 at all, and on Android 16 the +// value the shell command prints is not in the parcel either -- `cmd wifi status` shows it only +// because it reads the object in system_server. No other call an app can make returns it, so +// the field stays empty rather than being guessed. +// * The IPv4 address. It is in the parcel, but FFWifiConnection has no field for it and +// `inf.description` is the interface name, as on every other platform. The LocalIP module +// reports addresses. +// * `inf.description` while the interface is not in the address list. Its name is not knowable +// from anywhere else -- sysfs and /proc/net are EACCES -- so the field stays empty rather than +// naming an interface that was guessed at. `inf.status` is still reported, from the radio. + +#define FF_WIFI_ANDROID_SERVICE "wifi" +#define FF_WIFI_ANDROID_DESCRIPTOR "android.net.wifi.IWifiManager" + +// Android 11 is the release that moved the Wi-Fi framework out of framework.jar and into an APEX, +// which is where this jar is. Nothing older carries it, so there is no second path to try: the +// class is either in the APEX or the release is too old. +#define FF_WIFI_ANDROID_JAR "/apex/com.android.wifi/javalib/framework-wifi.jar" +#define FF_WIFI_ANDROID_STUB "Landroid/net/wifi/IWifiManager$Stub;" +#define FF_WIFI_ANDROID_GET_CONNECTION_INFO "TRANSACTION_getConnectionInfo" +// Whether the radio is on is the one thing the connection does not say. An empty `WifiInfo` is what +// the service answers with both while Wi-Fi is off and while it is on with nothing associated, so +// the two states are told apart by asking the service directly. The method takes no arguments and +// answers with a `WifiManager.WIFI_STATE_*`. +#define FF_WIFI_ANDROID_GET_WIFI_ENABLED_STATE "TRANSACTION_getWifiEnabledState" +#define FF_WIFI_ANDROID_WIFI_STATE_DISABLED 1 +#define FF_WIFI_ANDROID_WIFI_STATE_UNKNOWN 4 + +// The other argument is the caller's package name. The shell UID owns exactly one package and the +// service accepts that name, which is what `cmd` and `dumpsys` pass. See getOwnPackage(). +#define FF_WIFI_ANDROID_SHELL_UID 2000 +#define FF_WIFI_ANDROID_SHELL_PACKAGE "com.android.shell" + +// An AIDL reply opens with the exception code and, for a Parcelable return, a non-null marker; +// WifiInfo itself then starts at 8. The network id, the RSSI and the link speed have held those +// offsets from Android 9 to 16, and the transmit speed joined them in Android 10 without moving +// since, so all four are read at fixed offsets. Everything after them is located by shape. +#define FF_WIFI_ANDROID_OFF_NET_ID 8 +#define FF_WIFI_ANDROID_OFF_RSSI 12 +#define FF_WIFI_ANDROID_OFF_LINK_SPEED 16 +#define FF_WIFI_ANDROID_OFF_TX_LINK_SPEED 20 + +// A `WifiInfo` with no connection to describe carries the sentinels for the two fields that are +// always at a fixed offset: -1 for a network id it does not have and -127 for a signal it cannot +// measure. Both were measured on the vivo's Android 16 reply, which is what the service answers +// while Wi-Fi is on with nothing associated -- the head holds no frequency and no SSID at all, only +// the "02:00:00:00:00:00" placeholder that `WifiInfo` writes in place of a BSSID. +#define FF_WIFI_ANDROID_NET_ID_NONE (-1) +#define FF_WIFI_ANDROID_RSSI_NONE (-127) + +// Frequency is looked for in the head, after the link speeds and before the address. See the note +// above for why it is not at a fixed offset. +#define FF_WIFI_ANDROID_FREQ_SCAN_FROM 0x14 +#define FF_WIFI_ANDROID_FREQ_SCAN_TO 0x34 + +// Distances from Frequency. AOSP writes an address as a flag byte followed by a byte array, and +// omits the array entirely when there is no IPv4 address -- an IPv6-only network does that -- which +// shifts everything after it by two words. The base of the SSID section is therefore derived from +// that flag rather than fixed, and only the SSID's own offset is left as a pair: Android 11 wrote +// the length twice in front of the octets and Android 12 dropped the duplicate. +// +// The SSID is the one field WifiInfo writes as raw bytes rather than as a UTF-16 string, so its +// length is a byte count and a NUL among the bytes means the offset was wrong. +#define FF_WIFI_ANDROID_FREQ_HAS_IP 0x04 +#define FF_WIFI_ANDROID_FREQ_IP_LENGTH 0x08 +#define FF_WIFI_ANDROID_FREQ_SSID_BASE 0x10 +#define FF_WIFI_ANDROID_FREQ_SSID_BASE_NO_IP 0x08 +#define FF_WIFI_ANDROID_SSID_LENGTH 0x04 + +// The receive speed is the word in front of Frequency; the transmit speed is the word after the link +// speed. The vivo writes one extra word between those two that AOSP does not -- it repeats the +// transmit speed -- so the receive speed can only be reached from Frequency, and Frequency cannot be +// reached by stepping over the two link speeds. Both were checked against `cmd wifi status` at the +// same moment: 131/219 on the vivo, 86/-1 on the Redmi. +#define FF_WIFI_ANDROID_FREQ_RX_LINK_SPEED 0x04 +#define FF_WIFI_ANDROID_SSID 0x08 // Android 12 and later +#define FF_WIFI_ANDROID_SSID_LEGACY 0x0c // Android 11 and older +#define FF_WIFI_ANDROID_SSID_MAX_LENGTH 32 + +// `WifiInfo` writes the Wi-Fi standard as one of the `ScanResult.WIFI_STANDARD_*` values. It is +// followed by the two maximum-supported link speeds, which is what makes it recognisable: an int +// naming a standard, then two plausible rates. The band is checked against it as well, so a value +// that only fits another band is rejected. A layout that is not recognised leaves `protocol` empty +// instead of reporting the wrong standard. +#define FF_WIFI_ANDROID_STANDARD_LEGACY 1 +#define FF_WIFI_ANDROID_STANDARD_11A 2 +#define FF_WIFI_ANDROID_STANDARD_11B 3 +#define FF_WIFI_ANDROID_STANDARD_11N 4 +#define FF_WIFI_ANDROID_STANDARD_11AC 5 +#define FF_WIFI_ANDROID_STANDARD_11AX 6 +#define FF_WIFI_ANDROID_STANDARD_11AD 7 +#define FF_WIFI_ANDROID_STANDARD_11BE 8 +#define FF_WIFI_ANDROID_RATE_MAX 20000 + +// The whole WifiInfo parcel measured 1340 bytes on Android 16, most of it the MLO and ANQP tail. +#define FF_WIFI_ANDROID_REPLY_SIZE 4096 + +// The layout is found by shape rather than by offset, so a rejected reply leaves nothing to go on. +// The debug dump covers the head -- where the frequency, the address flag and the SSID sit -- and +// stops before the tail, which is the long MLO and ANQP list and takes no part in locating +// anything. Read with `fastfetch -s Wifi` under a debug build. +#define FF_WIFI_ANDROID_DEBUG_DUMP_SIZE 0x60 + +// --------------------------------------------------------------------------------------------- +// Binder +// --------------------------------------------------------------------------------------------- + +// `getConnectionInfo` takes the caller's package name, and whether it is checked depends on the +// release: the vivo answered "Package com.termux does not belong to 2000" for a name the shell does +// not own, while the Redmi accepted the same name. Passing the real one is what works on both. There +// is no way for a process to ask for its own package name -- it is not in /proc/self/status, and an +// app cannot list /data/data -- but the executable path carries it: an app's binaries live under +// /data/data// or /data/user///, and /proc/self/exe resolves there. +// +// A binary outside those directories has no package of its own, and that is not an edge case: a +// static build pushed to /data/local/tmp is how this runs on a device without Termux, and the Redmi +// reported "Cannot determine the package name of this process" for exactly that. The shell UID can +// be answered instead, because it has one name the service accepts. No other UID reaches here: an +// app's own binaries are always under its data directory, so a failure there is a real failure. +static bool getOwnPackage(char* buffer, size_t capacity) { + char path[4096]; + const ssize_t length = readlink("/proc/self/exe", path, sizeof(path) - 1); + if (length <= 0) { + FF_DEBUG("Cannot read /proc/self/exe, so the calling package cannot be derived"); + return false; + } + path[length] = '\0'; + + const char* rest = nullptr; + const char* const dataPrefix = "/data/data/"; + const char* const userPrefix = "/data/user/"; + if (strncmp(path, dataPrefix, strlen(dataPrefix)) == 0) { + rest = path + strlen(dataPrefix); + } else if (strncmp(path, userPrefix, strlen(userPrefix)) == 0) { + rest = strchr(path + strlen(userPrefix), '/'); + if (rest != nullptr) { + rest += 1; + } + } + if (rest == nullptr) { + FF_DEBUG("The executable is not in an app data directory (\"%s\"), uid %u", path, (unsigned) getuid()); + if (getuid() != FF_WIFI_ANDROID_SHELL_UID) { + return false; + } + static const char shellPackage[] = FF_WIFI_ANDROID_SHELL_PACKAGE; + if (sizeof(shellPackage) > capacity) { + return false; + } + memcpy(buffer, shellPackage, sizeof(shellPackage)); + FF_DEBUG("The calling package is \"%s\" (the shell owns it)", buffer); + return true; + } + + const char* end = strchr(rest, '/'); + const size_t nameLength = end != nullptr ? (size_t) (end - rest) : strlen(rest); + if (nameLength == 0 || nameLength >= capacity) { + return false; + } + memcpy(buffer, rest, nameLength); + buffer[nameLength] = '\0'; + FF_DEBUG("The calling package is \"%s\"", buffer); + return true; +} + +static bool isMacAddress(const char* value, uint32_t length) { + if (length != 17) { + return false; + } + for (uint32_t i = 0; i < length; ++i) { + const char c = value[i]; + if (i % 3 == 2) { + if (c != ':') { + return false; + } + } else if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +// SupplicantState, in the order AOSP declares it. +static const char* const FF_WIFI_ANDROID_STATES[] = { + "DISCONNECTED", "INTERFACE_DISABLED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", + "ASSOCIATED", "FOUR_WAY_HANDSHAKE", "GROUP_HANDSHAKE", "COMPLETED", "DORMANT", "UNINITIALIZED", "INVALID", +}; + +static bool isSupplicantState(const char* value, uint32_t length) { + for (uint32_t i = 0; i < ARRAY_SIZE(FF_WIFI_ANDROID_STATES); ++i) { + if (strlen(FF_WIFI_ANDROID_STATES[i]) == length && memcmp(FF_WIFI_ANDROID_STATES[i], value, length) == 0) { + return true; + } + } + return false; +} + +typedef struct FFWifiAndroidConnection { + char interface[IF_NAMESIZE + 1]; + char state[24]; + char ssid[FF_WIFI_ANDROID_SSID_MAX_LENGTH + 1]; + char bssid[18]; + int32_t rssi; + int32_t linkSpeed; + int32_t txLinkSpeed; + int32_t rxLinkSpeed; + int32_t standard; + size_t stateEnd; + uint16_t frequency; + bool up; + bool upKnown; // whether the state of the interface was established at all + bool connected; +} FFWifiAndroidConnection; + +// The Wi-Fi interface is the one the HAL names `wlan*`, and it is looked up rather than assumed so +// that a second one (`wlan1` on a dual-STA device) is still reported by name. The flags describe the +// interface, so the first entry for a name is as good as any other. +// +// What bionic's `getifaddrs` will not do is list an interface that has no address: the list is +// built out of the addresses, so `wlan0` leaves it as soon as nothing is associated -- measured on +// the vivo, and the Redmi behaves the same. That is exactly the state its flags are wanted in, so +// when the name is missing the radio state stands in for them. See detectWithBinder(). +static void detectInterface(FFWifiAndroidConnection* connection) { + struct ifaddrs* addrs = nullptr; + if (getifaddrs(&addrs) != 0) { + FF_DEBUG("getifaddrs failed, so neither the interface name nor its state is known"); + return; + } + + for (const struct ifaddrs* ifa = addrs; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_name == nullptr || strncmp(ifa->ifa_name, "wlan", 4) != 0) { + continue; + } + snprintf(connection->interface, sizeof(connection->interface), "%s", ifa->ifa_name); + connection->up = (ifa->ifa_flags & IFF_UP) != 0; + connection->upKnown = true; + FF_DEBUG("Interface \"%s\" is %s (IFF_UP %s)", connection->interface, connection->up ? "up" : "down", connection->up ? "set" : "clear"); + break; + } + freeifaddrs(addrs); + if (connection->interface[0] == '\0') { + FF_DEBUG("No interface named wlan* is present"); } } -const char* ffDetectWifi(FFlist* result) { - FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate(); +static const char* wifiStandardName(int32_t standard) { + switch (standard) { + case FF_WIFI_ANDROID_STANDARD_LEGACY: return "802.11"; + case FF_WIFI_ANDROID_STANDARD_11A: return "802.11a"; + case FF_WIFI_ANDROID_STANDARD_11B: return "802.11b"; + case FF_WIFI_ANDROID_STANDARD_11N: return "802.11n (Wi-Fi 4)"; + case FF_WIFI_ANDROID_STANDARD_11AC: return "802.11ac (Wi-Fi 5)"; + case FF_WIFI_ANDROID_STANDARD_11AX: return "802.11ax (Wi-Fi 6)"; + case FF_WIFI_ANDROID_STANDARD_11AD: return "802.11ad (WiGig)"; + case FF_WIFI_ANDROID_STANDARD_11BE: return "802.11be (Wi-Fi 7)"; + default: return nullptr; + } +} - if (ffProcessAppendStdOut(&buffer, (char* const[]) { FF_TERMUX_API_PATH, FF_TERMUX_API_PARAM, nullptr })) { - return "Starting `" FF_TERMUX_API_PATH " " FF_TERMUX_API_PARAM "` failed"; +// 11ac and 11ad live in bands the connection is not in when it reports a 2.4 GHz frequency, so a +// candidate that disagrees with the band is a misread rather than a standard. +static bool isStandardPlausible(int32_t standard, uint16_t frequency) { + if (frequency < 3000 && (standard == FF_WIFI_ANDROID_STANDARD_11AC || standard == FF_WIFI_ANDROID_STANDARD_11AD)) { + return false; + } + if (frequency > 50000 && standard != FF_WIFI_ANDROID_STANDARD_11AD) { + return false; + } + return true; +} + +// WifiInfo writes the BSSID, the MAC and the supplicant state as length-prefixed UTF-16 strings, and +// their distance from the head depends on how long the SSID is and on which release wrote the +// parcel. They are recognised by shape instead of by offset: a 17 character `hh:hh:hh:hh:hh:hh`, and +// one of the thirteen state names. The BSSID is written before the MAC, so the first match is the +// one wanted. `from` is the end of the SSID octets, which is where the BSSID follows. +static void findStrings(const uint8_t* data, size_t size, size_t from, FFWifiAndroidConnection* connection) { + for (size_t offset = from; offset + 4 <= size; ++offset) { + const uint32_t length = ffBinderReadU32(data, size, offset); + if (length == 0 || length > 24 || offset + 4 + (size_t) length * 2 > size) { + continue; + } + + const uint8_t* chars = data + offset + 4; + char buffer[25]; + bool ascii = true; + for (uint32_t i = 0; i < length; ++i) { + if (chars[i * 2 + 1] != 0 || chars[i * 2] < 0x20 || chars[i * 2] >= 0x7f) { + ascii = false; + break; + } + buffer[i] = (char) chars[i * 2]; + } + if (!ascii) { + continue; + } + buffer[length] = '\0'; + + if (connection->bssid[0] == '\0' && isMacAddress(buffer, length)) { + memcpy(connection->bssid, buffer, length + 1); + } else if (connection->state[0] == '\0' && isSupplicantState(buffer, length)) { + memcpy(connection->state, buffer, length + 1); + // The state is the last string before the tail, so where it ends is where the tail + // starts. A Parcel pads a string to the next word, terminator included. + connection->stateEnd = offset + 4 + ((length * 2 + 2 + 3) & ~3u); + } + if (connection->bssid[0] != '\0' && connection->state[0] != '\0') { + return; + } + } +} + +// AOSP writes the Wi-Fi standard between three nullable strings and the two maximum-supported link +// speeds, all of them ints. Vendors insert fields around that run -- the Android 11 build on the +// Redmi carries three words before the standard that AOSP's own Android 11 does not -- so a fixed +// offset would be a guess. The run is recognised by shape instead, searching forward from the end of +// the supplicant state: an int naming a standard, followed by two rates, in a band the standard can +// live in. +static void findWifiStandard(const uint8_t* data, size_t size, FFWifiAndroidConnection* connection) { + if (connection->stateEnd == 0) { + return; // without the state string there is no trustworthy place to start from } - [[gnu::cleanup(wrapYyjsonFree)]] yyjson_doc* doc = yyjson_read_opts(buffer.chars, buffer.length, 0, nullptr, nullptr); - if (!doc) { - return "Failed to parse wifi connection info"; + for (size_t offset = connection->stateEnd; offset + 12 <= size; offset += 4) { + const int32_t standard = ffBinderReadI32(data, size, offset); + if (wifiStandardName(standard) == nullptr || !isStandardPlausible(standard, connection->frequency)) { + continue; + } + const int32_t maxTx = ffBinderReadI32(data, size, offset + 4); + const int32_t maxRx = ffBinderReadI32(data, size, offset + 8); + if (maxTx < 0 || maxTx > FF_WIFI_ANDROID_RATE_MAX || maxRx < 0 || maxRx > FF_WIFI_ANDROID_RATE_MAX) { + continue; + } + connection->standard = standard; + FF_DEBUG("Wi-Fi standard %d at +0x%zx, maximum speeds %d/%d", standard, offset, maxTx, maxRx); + return; + } + FF_DEBUG("No Wi-Fi standard matched after the supplicant state at +0x%zx", connection->stateEnd); +} + +#ifndef NDEBUG +// The reply is read by shape, so a reply that fails every shape leaves nothing behind to diagnose +// it with: the bytes are the only evidence. Each line carries the words next to their bytes, +// because what the reader is looking for -- a value that would pass for a frequency, an address +// flag, a string length -- is an int. +static void debugDumpReply(const uint8_t* data, size_t size) { + const size_t limit = size < FF_WIFI_ANDROID_DEBUG_DUMP_SIZE ? size : FF_WIFI_ANDROID_DEBUG_DUMP_SIZE; + for (size_t offset = 0; offset < limit; offset += 16) { + char hex[16 * 3 + 1]; + size_t used = 0; + for (size_t i = 0; i < 16 && offset + i < limit; ++i) { + used += (size_t) snprintf(hex + used, sizeof(hex) - used, "%02x ", data[offset + i]); + } + FF_DEBUG(" +0x%02zx %-47s | %d %d %d %d", offset, hex, + ffBinderReadI32(data, size, offset), ffBinderReadI32(data, size, offset + 4), + ffBinderReadI32(data, size, offset + 8), ffBinderReadI32(data, size, offset + 12)); + } +} + #define FF_WIFI_ANDROID_DEBUG_DUMP(data, size) debugDumpReply(data, size) +#else + #define FF_WIFI_ANDROID_DEBUG_DUMP(data, size) ((void) 0) +#endif + +static const char* parseConnectionInfo(const uint8_t* data, size_t size, FFWifiAndroidConnection* connection) { + if (size < FF_WIFI_ANDROID_OFF_TX_LINK_SPEED + 4) { + FF_DEBUG("The reply is %zu bytes, too short to hold a WifiInfo", size); + return "The reply is too short to hold a WifiInfo"; + } + const int32_t exception = ffBinderReadI32(data, size, 0); + if (exception != 0) { + FF_DEBUG("The Wifi service raised exception %d", exception); + return "The Wifi service raised an exception"; + } + if (ffBinderReadU32(data, size, 4) == 0) { + // A null WifiInfo is what the service returns when it has nothing to report, which is the + // normal reply while Wi-Fi is off. The interface, when it is still there, is reported as + // disconnected. + FF_DEBUG("The reply is %zu bytes and carries no WifiInfo", size); + return nullptr; + } + FF_DEBUG("Reply is %zu bytes: netId %d, rssi %d, link speed %d, tx link speed %d", + size, + ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_NET_ID), + ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_RSSI), + ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_LINK_SPEED), + ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_TX_LINK_SPEED)); + + // The two orders WifiSsid has written its length in: once before the octets on Android 11 and + // older, not at all on Android 12 and later, which leaves the octets one word earlier. The one + // that matches this release is tried first. + uint32_t ssidOffsets[2] = { FF_WIFI_ANDROID_SSID_LEGACY, FF_WIFI_ANDROID_SSID }; + if (FF_API_AT_LEAST(31)) { + ssidOffsets[0] = FF_WIFI_ANDROID_SSID; + ssidOffsets[1] = FF_WIFI_ANDROID_SSID_LEGACY; } - yyjson_val* root = yyjson_doc_get_root(doc); - if (!yyjson_is_obj(root)) { - return "Wifi info result is not a JSON object"; + for (uint32_t base = FF_WIFI_ANDROID_FREQ_SCAN_FROM; base <= FF_WIFI_ANDROID_FREQ_SCAN_TO; base += 4) { + const uint16_t frequency = (uint16_t) ffBinderReadU32(data, size, base); + if (frequency == 0 || ffWifiFreqToChannel(frequency) == 0) { + continue; + } + FF_DEBUG("+0x%02x holds %u, which is channel %u", base, frequency, ffWifiFreqToChannel(frequency)); + + const int32_t hasIp = ffBinderReadI32(data, size, base + FF_WIFI_ANDROID_FREQ_HAS_IP); + if (hasIp != 0 && hasIp != 1) { + FF_DEBUG(" rejected: +0x%02x is not an address flag but %d", base + FF_WIFI_ANDROID_FREQ_HAS_IP, hasIp); + continue; + } + const uint32_t ipLength = ffBinderReadU32(data, size, base + FF_WIFI_ANDROID_FREQ_IP_LENGTH); + if (hasIp == 1 && ipLength != 4) { + FF_DEBUG(" rejected: the address at +0x%02x is %u bytes long", base + FF_WIFI_ANDROID_FREQ_IP_LENGTH, ipLength); + continue; + } + const uint32_t ssidBase = base + (hasIp == 1 ? FF_WIFI_ANDROID_FREQ_SSID_BASE : FF_WIFI_ANDROID_FREQ_SSID_BASE_NO_IP); + const int32_t ssidPresent = ffBinderReadI32(data, size, ssidBase); + if (ssidPresent != 1) { + FF_DEBUG(" rejected: the SSID at +0x%02x is marked %d, not present", ssidBase, ssidPresent); + continue; // the SSID of a reported connection is never null + } + + const uint32_t ssidLength = ffBinderReadU32(data, size, ssidBase + FF_WIFI_ANDROID_SSID_LENGTH); + if (ssidLength == 0 || ssidLength > FF_WIFI_ANDROID_SSID_MAX_LENGTH) { + FF_DEBUG(" rejected: the SSID length at +0x%02x is %u", ssidBase + FF_WIFI_ANDROID_SSID_LENGTH, ssidLength); + continue; + } + + for (uint32_t i = 0; i < ARRAY_SIZE(ssidOffsets); ++i) { + const size_t ssidOffset = ssidBase + ssidOffsets[i]; + if (ssidOffset + ssidLength > size) { + continue; + } + + // The SSID is a raw byte string, so a NUL in it means this was not the SSID after all. + if (memchr(data + ssidOffset, '\0', ssidLength) != nullptr) { + FF_DEBUG(" rejected: the %u SSID bytes at +0x%zx hold a NUL", ssidLength, ssidOffset); + continue; + } + memcpy(connection->ssid, data + ssidOffset, ssidLength); + connection->ssid[ssidLength] = '\0'; + + connection->frequency = frequency; + connection->rssi = ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_RSSI); + connection->linkSpeed = ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_LINK_SPEED); + connection->txLinkSpeed = ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_TX_LINK_SPEED); + connection->rxLinkSpeed = ffBinderReadI32(data, size, base - FF_WIFI_ANDROID_FREQ_RX_LINK_SPEED); + + findStrings(data, size, ssidOffset + ssidLength, connection); + FF_DEBUG("SSID \"%s\" at +0x%zx, BSSID \"%s\", supplicant state \"%s\"", + connection->ssid, ssidOffset, connection->bssid, connection->state); + // The supplicant state is the authoritative signal; the network id is the fallback for a + // parcel whose state string was not recognised. + connection->connected = connection->state[0] != '\0' + ? strcmp(connection->state, "COMPLETED") == 0 + : ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_NET_ID) >= 0; + findWifiStandard(data, size, connection); + FF_DEBUG("Standard %d (%s), rx link speed %d, connected %s", + connection->standard, wifiStandardName(connection->standard) ?: "unknown", connection->rxLinkSpeed, + connection->connected ? "yes" : "no"); + return nullptr; + } + } + + // Nothing in the head matched, but an unassociated connection has nothing in the head to match: + // no frequency, no SSID and -- unlike a connection -- the two sentinels `WifiInfo` fills in when + // it has nothing to describe. That reply is the service saying there is nothing to report, which + // is a state and not a failure. A reply carrying a network id of its own is a connection, so a + // shape that still does not match one keeps being reported as a failure. + if (ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_NET_ID) == FF_WIFI_ANDROID_NET_ID_NONE && + ffBinderReadI32(data, size, FF_WIFI_ANDROID_OFF_RSSI) == FF_WIFI_ANDROID_RSSI_NONE) { + FF_DEBUG("No network id and no signal in the %zu byte reply: there is no connection to report", size); + return nullptr; + } + + FF_DEBUG("No frequency, no SSID and no supplicant state matched in this reply:"); + FF_WIFI_ANDROID_DEBUG_DUMP(data, size); + return "The Wifi service returned a layout fastfetch does not understand"; +} + +// Which of the speeds the service wrote becomes a rate. It writes a negative one when it does not +// know, which is what the Redmi does for the receive side, and a link speed of 0 when the link is +// down. `generic` is the fallback for a direction the service left unset: the transmit side has one +// -- the generic link speed is a real negotiated rate, just not direction-specific -- the way the +// Linux backend falls back to SIOCGIWRATE when the station info carries no bitrate. There is no +// generic receive speed, so 0 is passed there. +static double wifiAndroidRate(int32_t specific, int32_t generic) { + const int32_t rate = specific > 0 ? specific : generic; + return rate > 0 && rate <= FF_WIFI_ANDROID_RATE_MAX ? (double) rate : -DBL_MAX; +} + +// Calls a method of the service that takes no arguments and answers with an int. The transaction +// code comes out of the jar the same way `getConnectionInfo`'s does, so a release that does not +// declare the method answers UNKNOWN_TRANSACTION instead of a number that means something else. +static const char* callIntMethod(FFBinder* binder, uint32_t handle, const char* transactionField, int32_t* result) { + int32_t transaction = 0; + const char* error = ffDexStaticInt(FF_WIFI_ANDROID_JAR, FF_WIFI_ANDROID_STUB, transactionField, &transaction); + if (error != nullptr) { + return error; + } + + uint8_t parcelBuffer[256]; + FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer)); + ffBinderParcelPutInterfaceToken(&parcel, FF_WIFI_ANDROID_DESCRIPTOR); + + uint8_t replyBuffer[64]; + FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer)); + error = ffBinderTransact(binder, handle, (uint32_t) transaction, &parcel, &reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(&reply)) { + FF_DEBUG("\"%s\" resolved to transaction %d, which this build does not answer", transactionField, transaction); + return "The Wifi service does not answer that request"; + } + const int32_t exception = ffBinderReadI32(reply.data, reply.size, 0); + if (exception != 0) { + FF_DEBUG("\"%s\" is transaction %d and raised exception %d", transactionField, transaction, exception); + return "The Wifi service raised an exception"; + } + *result = ffBinderReadI32(reply.data, reply.size, 4); + FF_DEBUG("\"%s\" is transaction %d and answered %d", transactionField, transaction, *result); + return nullptr; +} + +static const char* detectWithBinder(FFlist* result) { + char package[128]; + if (!getOwnPackage(package, sizeof(package))) { + return "Cannot determine the package name of this process"; + } + + int32_t transaction = 0; + const char* error = ffDexStaticInt(FF_WIFI_ANDROID_JAR, FF_WIFI_ANDROID_STUB, FF_WIFI_ANDROID_GET_CONNECTION_INFO, &transaction); + if (error != nullptr) { + FF_DEBUG("Reading the transaction code from \"%s\" failed: %s", FF_WIFI_ANDROID_JAR, error); + return error; + } + + [[gnu::cleanup(ffBinderClose)]] FFBinder binder = { .fd = -1 }; + error = ffBinderOpen(&binder); + if (error != nullptr) { + return error; + } + + uint32_t handle = 0; + error = ffBinderLookupService(&binder, FF_WIFI_ANDROID_SERVICE, FF_BINDER_SM_GET_SERVICE, &handle); + if (error != nullptr) { + return error; + } + FF_DEBUG("The \"%s\" service is handle %u, %s is transaction %d", + FF_WIFI_ANDROID_SERVICE, handle, FF_WIFI_ANDROID_GET_CONNECTION_INFO, transaction); + + uint8_t parcelBuffer[256]; + FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer)); + ffBinderParcelPutInterfaceToken(&parcel, FF_WIFI_ANDROID_DESCRIPTOR); + ffBinderParcelPutString16(&parcel, package); + // callingFeatureId is nullable, and a null String is a length of -1 rather than an empty string. + ffBinderParcelPutI32(&parcel, -1); + + uint8_t replyBuffer[FF_WIFI_ANDROID_REPLY_SIZE]; + FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer)); + error = ffBinderTransact(&binder, handle, (uint32_t) transaction, &parcel, &reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(&reply)) { + FF_DEBUG("The reply carries status %d instead of a parcel", (int) ffBinderReadI32(reply.data, reply.size, 0)); + return "Wifi service rejected the request"; + } + + FFWifiAndroidConnection connection = {}; + detectInterface(&connection); + error = parseConnectionInfo(reply.data, reply.size, &connection); + if (error != nullptr) { + return error; + } + + if (!connection.upKnown) { + // `getifaddrs` leaves the interface out while it has no address, so its flags are missing in + // exactly the state they are wanted in. The radio answers for it: a Wi-Fi that is off is one + // whose interface is down, and a Wi-Fi that is on with nothing associated keeps an interface + // that is up. WIFI_STATE_DISABLING is a radio that is still up, so only the one value means + // down, and WIFI_STATE_UNKNOWN is the service declining to say. + int32_t state = 0; + const char* stateError = callIntMethod(&binder, handle, FF_WIFI_ANDROID_GET_WIFI_ENABLED_STATE, &state); + if (stateError == nullptr && state != FF_WIFI_ANDROID_WIFI_STATE_UNKNOWN) { + connection.up = state != FF_WIFI_ANDROID_WIFI_STATE_DISABLED; + connection.upKnown = true; + FF_DEBUG("The radio is %s, which stands in for the interface flags", connection.up ? "on" : "off"); + } else { + FF_DEBUG("The state of the radio is not known: %s", + stateError != nullptr ? stateError : "the service answered WIFI_STATE_UNKNOWN"); + } + } + + // Nothing was found to report on at all. That is a device without a Wi-Fi interface rather than + // one with a Wi-Fi that is off, which has a state to print by now. + if (!connection.upKnown && !connection.connected) { + return "No Wi-Fi interface is present"; } FFWifiResult* item = FF_LIST_ADD(FFWifiResult, *result); @@ -45,26 +675,40 @@ const char* ffDetectWifi(FFlist* result) { item->conn.channelWidth = 0; item->conn.frequency = 0; - ffStrbufAppendJsonVal(&item->inf.status, yyjson_obj_get(root, "supplicant_state")); - if (!item->inf.status.length) { - ffStrbufAppendS(&item->inf.status, "Unknown"); + ffStrbufSetS(&item->inf.description, connection.interface); + // The name is empty when the interface was not in the address list, and the state is empty when + // nothing established it: both are left empty rather than guessed at. The module prints this + // state whenever there is no SSID to print instead, which is exactly the case where neither is + // in the reply. + if (connection.upKnown) { + ffStrbufSetStatic(&item->inf.status, connection.up ? "Up" : "Down"); + } + ffStrbufSetStatic(&item->conn.status, connection.connected ? "connected" : "disconnected"); + if (!connection.connected) { + FF_DEBUG("Nothing is associated: interface \"%s\" is \"%s\", the connection is \"%s\"", + connection.interface, item->inf.status.chars, item->conn.status.chars); return nullptr; } - if (!ffStrbufEqualS(&item->inf.status, "COMPLETED")) { - return nullptr; + const char* protocol = wifiStandardName(connection.standard); + if (protocol != nullptr) { + ffStrbufSetStatic(&item->conn.protocol, protocol); } - - double rssi = yyjson_get_num(yyjson_obj_get(root, "rssi")); - item->conn.signalQuality = rssi >= -50 ? 100 : rssi <= -100 ? 0 - : (rssi + 100) * 2; - - ffStrbufAppendJsonVal(&item->inf.description, yyjson_obj_get(root, "ip")); - ffStrbufAppendJsonVal(&item->conn.bssid, yyjson_obj_get(root, "bssid")); - ffStrbufAppendJsonVal(&item->conn.ssid, yyjson_obj_get(root, "ssid")); - item->conn.frequency = (uint16_t) yyjson_get_int(yyjson_obj_get(root, "frequency_mhz")); - item->conn.txRate = yyjson_get_num(yyjson_obj_get(root, "link_speed_mbps")); - item->conn.channel = ffWifiFreqToChannel(item->conn.frequency); - + item->conn.signalQuality = connection.rssi >= -50 ? 100 : connection.rssi <= -100 ? 0 + : (connection.rssi + 100) * 2; + ffStrbufSetS(&item->conn.bssid, connection.bssid); + ffStrbufSetS(&item->conn.ssid, connection.ssid); + item->conn.frequency = connection.frequency; + item->conn.txRate = wifiAndroidRate(connection.txLinkSpeed, connection.linkSpeed); + item->conn.rxRate = wifiAndroidRate(connection.rxLinkSpeed, 0); + item->conn.channel = ffWifiFreqToChannel(connection.frequency); + FF_DEBUG("\"%s\" %s: \"%s\", \"%s\", %s, %u MHz (channel %u), signal %.0f, tx %d, rx %d", + connection.interface, item->inf.status.chars, item->conn.ssid.chars, item->conn.bssid.chars, + item->conn.protocol.length ? item->conn.protocol.chars : "(no standard)", connection.frequency, + item->conn.channel, item->conn.signalQuality, connection.txLinkSpeed, connection.rxLinkSpeed); return nullptr; } + +const char* ffDetectWifi(FFlist* result) { + return detectWithBinder(result); +}