From 2838dccbfd5811bfaead83677e1d3dfd3d2d6705 Mon Sep 17 00:00:00 2001 From: Carter Li Date: Tue, 29 Sep 2026 22:39:15 +0800 Subject: [PATCH] Sound (Android): adds support via /dev/binder --- CHANGELOG.md | 1 + CMakeLists.txt | 2 +- src/common/android/dex.c | 289 +++++++++---- src/common/android/dex.h | 3 +- src/detection/sound/sound_android.c | 608 ++++++++++++++++++++++++++++ 5 files changed, 829 insertions(+), 74 deletions(-) create mode 100644 src/detection/sound/sound_android.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 25430d03f..9d3381b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Changes: Features: * Improved performance of LocalIP, NetIO and DNS on Windows +* Added Sound detection on Android. (Sound, Android) Bugfixes: * Fixed `--config`/`-c` bash completion offering one candidate per word instead of one candidate per file for paths containing spaces. (Completion, Bash) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9fcc85e2d..d615923de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -706,7 +706,7 @@ elseif(ANDROID) src/detection/packages/packages_nix.c src/detection/poweradapter/poweradapter_nosupport.c src/detection/processes/processes_linux.c - src/detection/sound/sound_linux.c + src/detection/sound/sound_android.c src/detection/swap/swap_linux.c src/detection/terminalfont/terminalfont_android.c src/detection/terminalfont/terminalfont_linux.c diff --git a/src/common/android/dex.c b/src/common/android/dex.c index e8e68a81c..4926e3338 100644 --- a/src/common/android/dex.c +++ b/src/common/android/dex.c @@ -1,6 +1,7 @@ #include "common/android/dex.h" #include +#include #include #include #include @@ -32,19 +33,48 @@ #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. +// The zip local file header, from APPNOTE.TXT 4.3.7. Only the two length fields are read from it: +// the payload starts right behind them, and the local extra field is allowed to differ from the +// central one, so the offset cannot be computed from the directory alone. #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 + +// The central directory. This is what makes an entry reachable without walking everything in front +// of it, and that matters here: framework.jar is 52 MB and its classes2.dex starts 9.6 MB in, so +// looking for a local header by scanning the file costs a pass over everything before it -- once per +// lookup. Measured on the test device, where the sound module resolves seven codes, that was the +// difference between 30 ms and 2 seconds. +#define FF_ZIP_CENTRAL_MAGIC "PK\x01\x02" +#define FF_ZIP_CENTRAL_SIZE 46 +#define FF_ZIP_OFF_CENTRAL_METHOD 10 +#define FF_ZIP_OFF_CENTRAL_COMPRESSED_SIZE 20 +#define FF_ZIP_OFF_CENTRAL_UNCOMPRESSED_SIZE 24 +#define FF_ZIP_OFF_CENTRAL_NAME_LENGTH 28 +#define FF_ZIP_OFF_CENTRAL_EXTRA_LENGTH 30 +#define FF_ZIP_OFF_CENTRAL_COMMENT_LENGTH 32 +#define FF_ZIP_OFF_CENTRAL_LOCAL_OFFSET 42 + +// The end of central directory record, which points at the directory. It is the last thing in the +// file, behind a comment of at most 64 KiB, so it is searched for backwards over that much. +#define FF_ZIP_EOCD_MAGIC "PK\x05\x06" +#define FF_ZIP_EOCD_SIZE 22 +#define FF_ZIP_EOCD_MAX_COMMENT 0xffffu +#define FF_ZIP_OFF_EOCD_DIRECTORY_SIZE 12 +#define FF_ZIP_OFF_EOCD_DIRECTORY 16 + +// A size or offset of 0xFFFFFFFF means the real one is in a zip64 extra field. AOSP's framework jars +// are orders of magnitude below that, and carrying the extra field reader for them is not worth it. +#define FF_ZIP_ZIP64_SENTINEL 0xffffffffu + #define FF_ZIP_METHOD_STORED 0 #define FF_ZIP_METHOD_DEFLATED 8 #define FF_DEX_ENTRY "classes.dex" +// How many `classesN.dex` entries a jar is walked for. A jar that spreads its classes over more +// than this still resolves everything in the entries that were walked; only a class that lives in +// one of the ones past the bound is reported as missing. +#define FF_DEX_MAX_ENTRIES 10 #define FF_DEX_MAGIC "dex\n" static uint16_t dexU16(const uint8_t* p) { @@ -96,18 +126,34 @@ static uint32_t dexUleb128(const uint8_t** p, const uint8_t* end) { } // The type tag a dex encoded_value carries in the low five bits of its header byte, per the -// `encoded_value` table of the dex format (`VALUE_INT = 0x04`, sign-extended four-byte integer). -// 0x1f is VALUE_BOOLEAN in that same table, which is the trap: naming it VALUE_INT and comparing -// against it rejects every real int and accepts booleans. -#define FF_DEX_VALUE_INT 0x04 +// `encoded_value` table of the dex format. +// +// VALUE_NULL and VALUE_BOOLEAN are the trap. Their value *is* the header -- the type for a null, +// the size nibble for a boolean -- and they carry **no payload bytes at all**, so the +// `((size - 1) << 5) | type` rule that describes every other tag does not describe them. Taking the +// size nibble at face value and reading one payload byte walks one byte too far, which pairs every +// field after that with the wrong value. It is not a hypothetical: `IAudioService$Stub` declares ten +// `PERMISSIONS_*` arrays that are all null, right behind its `DESCRIPTOR` string and ahead of its +// 324 transaction constants, and those ten spurious bytes made +// `TRANSACTION_getDevicesForAttributesUnprotected` read as 210 instead of 167 -- a code that reaches +// a different method. `IWifiManager$Stub` has none ahead of the field the wifi module reads, which +// is the only reason this went unnoticed. +// +// Naming VALUE_BOOLEAN also matters for a second reason: it is the tag a `static final boolean` +// carries, and treating it as VALUE_INT would hand back a boolean where an int was asked for. +typedef enum FFDexValueType : uint32_t { + FF_DEX_VALUE_INT = 0x04, // sign-extended; one to four payload bytes + FF_DEX_VALUE_NULL = 0x1e, // no payload + FF_DEX_VALUE_BOOLEAN = 0x1f, // no payload; the boolean is in the header's size nibble +} FFDexValueType; // encoded_value: one header byte holding ((size - 1) << 5) | type, then that many payload bytes. // // The `static_values` array is not a list of ints: `IInterface.DESCRIPTOR` is a `static final -// String` and sits in it, and a real Stub class mixes ints, strings and booleans. So a value whose -// tag is not VALUE_INT is skipped -- its payload is stepped over to keep the walk aligned with the -// field list -- and only `isInt` reports whether the *wanted* field ended up being one. Failing on -// the first non-int instead would abort on `DESCRIPTOR`, before the transaction constants that +// String` and sits in it, and a real Stub class mixes ints, strings, nulls and booleans. So a value +// whose tag is not VALUE_INT is skipped -- its payload is stepped over to keep the walk aligned with +// the field list -- and only `isInt` reports whether the *wanted* field ended up being one. Failing +// on the first non-int instead would abort on `DESCRIPTOR`, before the transaction constants that // follow it are ever reached. Measured on a device: `IWifiManager$Stub` carries 347 static fields // whose values are mostly VALUE_INT but include that string, and `getConnectionInfo` is 98 there. // @@ -121,6 +167,15 @@ static bool dexEncodedValue(const uint8_t** p, const uint8_t* end, int32_t* resu } const uint8_t header = *(*p)++; const uint32_t type = (uint32_t) (header & 0x1f); + + // The two tags whose value is the header byte itself. Returning here is what keeps the walk in + // step; `isInt` is false because neither is an int. + if (type == FF_DEX_VALUE_NULL || type == FF_DEX_VALUE_BOOLEAN) { + *result = 0; + *isInt = false; + return true; + } + const uint32_t size = (uint32_t) (header >> 5) + 1; if (size > 8) { return false; @@ -176,7 +231,12 @@ static const char* dexString(const uint8_t* dex, const uint8_t* end, uint32_t in // 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) { +// +// `found` separates "this dex does not define the class" from a real failure, because a jar spreads +// its classes over several dex files and the caller has to try the next one. It is set before any +// of the walks below, so an error after it still means the class was here. +static const char* dexStaticInt(const uint8_t* dex, size_t size, const char* classDescriptor, const char* fieldName, bool* found, int32_t* result) { + *found = false; const uint8_t* end = dex + size; if (size < FF_DEX_HEADER_SIZE || dexU32(dex + FF_DEX_OFF_ENDIAN_TAG) != FF_DEX_ENDIAN_TAG) { @@ -203,8 +263,9 @@ static const char* dexStaticInt(const uint8_t* dex, size_t size, const char* cla } } if (typeIndex == UINT32_MAX) { - return "The class is not in the dex"; + return nullptr; } + *found = true; const uint8_t* classes = dex + dexU32(dex + FF_DEX_OFF_CLASS_DEFS); const uint32_t classCount = dexU32(dex + FF_DEX_OFF_CLASS_DEFS_SIZE); @@ -294,44 +355,94 @@ static void wrapDexMapping(FFDexMapping* mapping) { } } -// 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; +// Locates an entry through the jar's central directory. +// +// The sizes and the compression method are read from the directory rather than from the entry's own +// local header, which is the more reliable of the two: a local header is allowed to leave its sizes +// at zero and put them in a data descriptor behind the payload, and an archive writer is free to +// choose that. Only the local header's two length fields are used, because they are what says where +// the payload begins -- the local extra field does not have to match the central one. +static const char* findDexEntry(const uint8_t* jar, size_t jarSize, const char* entry, const uint8_t** data, size_t* dataSize, uint32_t* uncompressedSize, uint16_t* method) { + const size_t entryLength = strlen(entry); + if (jarSize < FF_ZIP_EOCD_SIZE) { + return "The jar is too small to be a zip"; + } + + // The record is the last thing in the file, so it is searched for backwards -- over at most the + // 64 KiB a trailing comment can take, not over the whole archive. + const size_t newest = jarSize - FF_ZIP_EOCD_SIZE; + const size_t oldest = jarSize > FF_ZIP_EOCD_MAX_COMMENT + FF_ZIP_EOCD_SIZE + ? jarSize - FF_ZIP_EOCD_MAX_COMMENT - FF_ZIP_EOCD_SIZE + : 0; + size_t eocd = SIZE_MAX; + for (size_t i = newest;; --i) { + if (memcmp(jar + i, FF_ZIP_EOCD_MAGIC, sizeof(FF_ZIP_EOCD_MAGIC) - 1) == 0) { + eocd = i; + break; } - 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; + if (i == oldest) { + // The walk stops here rather than at zero: a `size_t` countdown that has to reach zero + // before it stops would wrap and read the whole file backwards, which is the very scan + // this is here to avoid. + break; } - const size_t headerSize = FF_ZIP_LOCAL_HEADER_SIZE + (size_t) nameLength + (size_t) extraLength; - if (headerSize > jarSize - i) { - continue; + } + if (eocd == SIZE_MAX) { + return "The jar has no zip directory"; + } + + const uint32_t directorySize = dexU32(jar + eocd + FF_ZIP_OFF_EOCD_DIRECTORY_SIZE); + const uint32_t directoryOffset = dexU32(jar + eocd + FF_ZIP_OFF_EOCD_DIRECTORY); + if (directoryOffset > jarSize || directorySize > jarSize - directoryOffset) { + return "The zip directory is out of range"; + } + + const size_t directoryEnd = (size_t) directoryOffset + directorySize; + for (size_t offset = directoryOffset; offset + FF_ZIP_CENTRAL_SIZE <= directoryEnd;) { + if (memcmp(jar + offset, FF_ZIP_CENTRAL_MAGIC, sizeof(FF_ZIP_CENTRAL_MAGIC) - 1) != 0) { + return "A zip directory entry is malformed"; } - if (memcmp(jar + i + FF_ZIP_LOCAL_HEADER_SIZE, FF_DEX_ENTRY, sizeof(FF_DEX_ENTRY) - 1) != 0) { - continue; + const uint16_t nameLength = dexU16(jar + offset + FF_ZIP_OFF_CENTRAL_NAME_LENGTH); + const uint16_t extraLength = dexU16(jar + offset + FF_ZIP_OFF_CENTRAL_EXTRA_LENGTH); + const uint16_t commentLength = dexU16(jar + offset + FF_ZIP_OFF_CENTRAL_COMMENT_LENGTH); + const size_t entrySize = FF_ZIP_CENTRAL_SIZE + (size_t) nameLength + (size_t) extraLength + (size_t) commentLength; + if (entrySize > directoryEnd - offset) { + return "A zip directory entry runs past the directory"; } - 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; + if (nameLength == entryLength && memcmp(jar + offset + FF_ZIP_CENTRAL_SIZE, entry, entryLength) == 0) { + const uint32_t compressedSize = dexU32(jar + offset + FF_ZIP_OFF_CENTRAL_COMPRESSED_SIZE); + const uint32_t uncompressed = dexU32(jar + offset + FF_ZIP_OFF_CENTRAL_UNCOMPRESSED_SIZE); + const uint32_t localOffset = dexU32(jar + offset + FF_ZIP_OFF_CENTRAL_LOCAL_OFFSET); + if (compressedSize == FF_ZIP_ZIP64_SENTINEL || uncompressed == FF_ZIP_ZIP64_SENTINEL || localOffset == FF_ZIP_ZIP64_SENTINEL) { + return "A dex entry is stored as zip64, which is not supported"; + } + if (localOffset > jarSize || FF_ZIP_LOCAL_HEADER_SIZE > jarSize - localOffset) { + return "A zip local header is out of range"; + } + + const uint16_t localNameLength = dexU16(jar + localOffset + FF_ZIP_OFF_NAME_LENGTH); + const uint16_t localExtraLength = dexU16(jar + localOffset + FF_ZIP_OFF_EXTRA_LENGTH); + const size_t headerSize = FF_ZIP_LOCAL_HEADER_SIZE + (size_t) localNameLength + (size_t) localExtraLength; + if (headerSize > jarSize - localOffset || compressedSize > jarSize - localOffset - headerSize) { + return "The zip entry bounds are out of range"; + } + + *method = dexU16(jar + offset + FF_ZIP_OFF_CENTRAL_METHOD); + *uncompressedSize = uncompressed; + *data = jar + localOffset + headerSize; + *dataSize = compressedSize; + return nullptr; } - *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; + + offset += entrySize; } - return "The jar has no usable classes.dex entry"; + return "The jar has no usable 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. +// literal run of bytes in the file, and the header that follows it validates or it does not. It +// only ever locates the *first* dex in the jar, so it is a fallback for `classes.dex` alone. 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) { @@ -410,37 +521,71 @@ const char* ffDexStaticInt(const char* jarPath, const char* classDescriptor, con 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"; + // A jar spreads its classes over `classes.dex`, `classes2.dex`, `classes3.dex`, ... and the + // class being looked for is in whichever one the build put it in: `IAudioService$Stub` is in the + // second dex of framework.jar, while `IWifiManager$Stub` is in the first of framework-wifi.jar. + // The entries are therefore tried in order, and the first one that defines the class answers. + // The count is a bound on the walk, not a claim about the jar -- AOSP has never shipped more + // than a handful, and a class that is in none of them is reported as missing either way. + const char* error = "The class is not in the jar"; + for (uint32_t index = 1; index <= FF_DEX_MAX_ENTRIES; ++index) { + char entry[sizeof("classes999.dex")]; + if (index == 1) { + memcpy(entry, FF_DEX_ENTRY, sizeof(FF_DEX_ENTRY)); + } else { + (void) snprintf(entry, sizeof(entry), "classes%u.dex", index); } - } else if (method == FF_ZIP_METHOD_DEFLATED) { - #ifdef FF_HAVE_ZLIB - const char* error = inflateDex(data, dataSize, uncompressedSize, &mapping.inflated); + + 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, entry, &data, &dataSize, &uncompressedSize, &method) != nullptr) { + if (index > 1) { + // The magic scan only ever finds the first dex, so it cannot stand in for a later + // entry: running past the end of the entries that exist ends the walk. + break; + } + data = findDexMagic(mapping.mapped, mapping.mappedSize, &dataSize); + if (data == nullptr) { + return "No dex in the jar"; + } + } else if (method == FF_ZIP_METHOD_DEFLATED) { + #ifdef FF_HAVE_ZLIB + // One buffer is reused across the walk, so the previous dex is released first. + free(mapping.inflated); + mapping.inflated = nullptr; + error = inflateDex(data, dataSize, uncompressedSize, &mapping.inflated); + if (error != nullptr) { + return error; + } + data = mapping.inflated; + dataSize = uncompressedSize; + #else + return "The jar deflates its dex entries and fastfetch was built without zlib"; + #endif + } else if (method != FF_ZIP_METHOD_STORED) { + return "A dex entry uses an unsupported compression method"; + } + // A STORED entry needs no further work: `dataSize` already holds the `compressedSize` that + // `findDexEntry` checked against the mapping, and for a STORED entry the payload is the file + // itself. The header's `uncompressedSize` is deliberately not used for it -- nothing bounds + // that value, so a corrupt one reaches past the end of the mapping, which is exactly what the + // dex header would then be validated against. + + mapping.data = data; + mapping.size = dataSize; + + bool found = false; + error = dexStaticInt(mapping.data, mapping.size, classDescriptor, fieldName, &found, result); 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 if (method != FF_ZIP_METHOD_STORED) { - return "classes.dex uses an unsupported compression method"; + if (found) { + return nullptr; + } + error = "The class is not in the jar"; } - // A STORED entry needs no further work: `dataSize` already holds the `compressedSize` that - // `findDexEntry` checked against the mapping, and for a STORED entry the payload is the file - // itself. The header's `uncompressedSize` is deliberately not used for it -- nothing bounds that - // value, so a corrupt one reaches past the end of the mapping, which is exactly what the dex - // header would then be validated against. - - mapping.data = data; - mapping.size = dataSize; - return dexStaticInt(mapping.data, mapping.size, classDescriptor, fieldName, result); + return error; } diff --git a/src/common/android/dex.h b/src/common/android/dex.h index 34197506a..4c44724f8 100644 --- a/src/common/android/dex.h +++ b/src/common/android/dex.h @@ -24,7 +24,8 @@ #include "fastfetch.h" -// Resolves the int value of `.` out of the `classes.dex` of `jarPath`. +// Resolves the int value of `.` out of the dex entries of `jarPath`, +// walking `classes.dex`, `classes2.dex`, ... in order until one of them defines the class. // `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/sound/sound_android.c b/src/detection/sound/sound_android.c new file mode 100644 index 000000000..116295c95 --- /dev/null +++ b/src/detection/sound/sound_android.c @@ -0,0 +1,608 @@ +#include "sound.h" + +#include "detection/displayserver/displayserver.h" +#include "common/android/binder.h" +#include "common/android/dex.h" +#include "common/debug.h" +#include "common/strutil.h" + +#include + +// Android has no desktop sound stack. PulseAudio is not part of the platform -- a Termux user can +// start one, and then the module reads that, but its sink is a virtual device of Termux's own that +// feeds Android's OpenSL ES output with a volume that is always at full scale, so the number says +// nothing about the phone. ALSA and OSS are not there either: /dev/snd, /proc/asound and +// /sys/class/sound are all EACCES from an app's SELinux domain, `dumpsys audio` needs +// android.permission.DUMP, and `settings get system volume_music` needs INTERACT_ACROSS_USERS. +// `cmd audio` is worse than useless: `cmd` hands the arguments to the service over binder and the +// command body runs inside system_server, so from an app it hangs or answers ENOSYS. +// +// What is left is the service those all sit on: `audio`, i.e. android.media.IAudioService, which +// lives in system_server. This file speaks to it over /dev/binder, the same way the battery, media +// and wifi modules do. Two things about it are worth stating up front, because they are what makes +// this module work at all: +// +// * Nothing here needs a permission. The methods used below are the ones `AudioManager` itself +// calls on behalf of an ordinary app, and the service checks no permission for them. The one +// that does the real work is deliberately named for it -- see getDevicesForAttributesUnprotected +// below. The protected twin, `getDevicesForAttributes`, is refused with +// "Missing MODIFY_AUDIO_ROUTING or QUERY_AUDIO_STATE permissions", and +// `getLastAudibleStreamVolume` with "requires android.permission.QUERY_AUDIO_STATE"; neither is +// used. So unlike the wifi module, this one works on a device where nothing has been granted. +// +// * Every transaction code is resolved from the device's own jar at run time, never written down. +// `IAudioService$Stub` declares 283 methods in AOSP and 324 on the test device -- a vendor fork +// inserts methods freely, and an insertion ahead of the one being called shifts it. Reading the +// constants out of the dex is the only thing that survives that; see common/android/dex.h. The +// class is in the *second* dex of framework.jar, which is why ffDexStaticInt walks the entries. +// +// The reply is not uniform, because the two kinds of service are not: IAudioService is Java, so a +// failure arrives as a parcel exception code (-1 for a SecurityException, -4 for a null argument), +// while the native services answer with a TF_STATUS_CODE carrying a raw status_t. Only the former +// can happen here, but the check is cheap and the two are easy to confuse. + +#define FF_SOUND_ANDROID_SERVICE "audio" +#define FF_SOUND_ANDROID_DESCRIPTOR "android.media.IAudioService" + +// IAudioService has always been part of framework.jar -- it is not one of the frameworks that moved +// into an APEX -- so there is one path and no fallback. The stub is in classes2.dex there. +#define FF_SOUND_ANDROID_JAR "/system/framework/framework.jar" +#define FF_SOUND_ANDROID_STUB "Landroid/media/IAudioService$Stub;" + +// The methods this module calls, by the name of the constant the dex carries for each. Spelling the +// names out rather than the numbers is the whole point: the lookup turns them into whatever this +// build uses, and a build that does not declare one says so instead of reaching another method. +#define FF_SOUND_ANDROID_GET_STREAM_VOLUME "TRANSACTION_getStreamVolume" +#define FF_SOUND_ANDROID_GET_STREAM_MIN_VOLUME "TRANSACTION_getStreamMinVolume" +#define FF_SOUND_ANDROID_GET_STREAM_MAX_VOLUME "TRANSACTION_getStreamMaxVolume" +#define FF_SOUND_ANDROID_IS_STREAM_MUTE "TRANSACTION_isStreamMute" +#define FF_SOUND_ANDROID_IS_MUSIC_ACTIVE "TRANSACTION_isMusicActive" +#define FF_SOUND_ANDROID_GET_DEVICES_FOR_ATTRIBUTES "TRANSACTION_getDevicesForAttributesUnprotected" +#define FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME "TRANSACTION_getBtActiveDeviceName" + +// `AudioManager.STREAM_MUSIC`. The one stream a phone's volume rocker moves, and the one the module +// reports; the other eleven are internal (SYSTEM_ENFORCED, DTMF, ...) or belong to a call. +typedef enum FFSoundAndroidStream : int32_t { + FF_SOUND_ANDROID_STREAM_MUSIC = 3, +} FFSoundAndroidStream; + +// `AudioAttributes.USAGE_MEDIA`. Asking the policy what it routes for media is what makes the answer +// "where the phone's sound is going"; other usages answer differently on purpose (a call goes to the +// earpiece, an emergency call to the telephony port). +typedef enum FFSoundAndroidUsage : int32_t { + FF_SOUND_ANDROID_USAGE_MEDIA = 1, +} FFSoundAndroidUsage; + +// `AudioAttributes.SOURCE_*`, and the one value that matters here. A request whose source is +// anything but AUDIO_SOURCE_INVALID takes the *capture* branch of +// AudioPolicyManager::getDevicesForAttributes and answers with microphones, whatever the usage says: +// +// if (attr.source != AUDIO_SOURCE_INVALID) return getInputDevicesForAttributes(attr, devices); +// +// AUDIO_SOURCE_DEFAULT is 0, so leaving the field at zero -- which is what a zeroed parcel does -- +// silently reports the built-in microphone as the output device. It has to be written out as -1. +#define FF_SOUND_ANDROID_SOURCE_INVALID (-1) + +// `AudioAttributes.ATTR_PARCEL_IS_NULL_BUNDLE`. The tags and the bundle are the only variable-length +// parts of the request; an empty tag list and no bundle is the smallest well-formed one. +#define FF_SOUND_ANDROID_ATTR_NULL_BUNDLE (-1977) + +// The interface token is 12 bytes of header plus a padded string16, and the largest request here adds +// one AudioAttributes to it. The replies are one int, or one list of AudioDeviceAttributes. The +// device reply is sized well past the 48 bytes a single device takes, because a reply that does not +// fit is reported as a buffer error and would hide the exception message the service sent instead. +#define FF_SOUND_ANDROID_SCALAR_PARCEL_SIZE 96 +#define FF_SOUND_ANDROID_SCALAR_REPLY_SIZE 32 +#define FF_SOUND_ANDROID_DEVICE_PARCEL_SIZE 128 +#define FF_SOUND_ANDROID_DEVICE_REPLY_SIZE 1024 + +// The reply to the Bluetooth device name request is one string16, and a device name is chosen by +// whoever owns the headset: 248 bytes is the longest a Bluetooth name can be, so 512 bytes of reply +// hold the count, the code units, the terminator and the padding with room to spare. A reply that +// does not fit is reported as a buffer error, so sizing it generously is what keeps a long name +// from looking like a failure. +#define FF_SOUND_ANDROID_NAME_REPLY_SIZE 512 + +// `AudioDeviceInfo.TYPE_*`, as the test device's own dex declares them (read out of +// `Landroid/media/AudioDeviceInfo;`). The value is what `AudioDeviceAttributes.type` carries in the +// reply; the names are the AOSP constant names without the prefix, which is what goes into +// `identifier`, and a spelled out name, which is what the module prints. An unrecognised value is +// reported rather than guessed at: a vendor is free to add types, and a wrong name is worse than +// "Unknown Device". +typedef enum FFSoundAndroidDeviceType : int32_t { + FF_SOUND_ANDROID_TYPE_UNKNOWN = 0, + FF_SOUND_ANDROID_TYPE_BUILTIN_EARPIECE = 1, + FF_SOUND_ANDROID_TYPE_BUILTIN_SPEAKER = 2, + FF_SOUND_ANDROID_TYPE_WIRED_HEADSET = 3, + FF_SOUND_ANDROID_TYPE_WIRED_HEADPHONES = 4, + FF_SOUND_ANDROID_TYPE_LINE_ANALOG = 5, + FF_SOUND_ANDROID_TYPE_LINE_DIGITAL = 6, + FF_SOUND_ANDROID_TYPE_BLUETOOTH_SCO = 7, + FF_SOUND_ANDROID_TYPE_BLUETOOTH_A2DP = 8, + FF_SOUND_ANDROID_TYPE_HDMI = 9, + FF_SOUND_ANDROID_TYPE_HDMI_ARC = 10, + FF_SOUND_ANDROID_TYPE_USB_DEVICE = 11, + FF_SOUND_ANDROID_TYPE_USB_ACCESSORY = 12, + FF_SOUND_ANDROID_TYPE_DOCK = 13, + FF_SOUND_ANDROID_TYPE_FM = 14, + FF_SOUND_ANDROID_TYPE_BUILTIN_MIC = 15, + FF_SOUND_ANDROID_TYPE_FM_TUNER = 16, + FF_SOUND_ANDROID_TYPE_TV_TUNER = 17, + FF_SOUND_ANDROID_TYPE_TELEPHONY = 18, + FF_SOUND_ANDROID_TYPE_AUX_LINE = 19, + FF_SOUND_ANDROID_TYPE_IP = 20, + FF_SOUND_ANDROID_TYPE_BUS = 21, + FF_SOUND_ANDROID_TYPE_USB_HEADSET = 22, + FF_SOUND_ANDROID_TYPE_HEARING_AID = 23, + FF_SOUND_ANDROID_TYPE_BUILTIN_SPEAKER_SAFE = 24, + FF_SOUND_ANDROID_TYPE_REMOTE_SUBMIX = 25, + FF_SOUND_ANDROID_TYPE_BLE_HEADSET = 26, + FF_SOUND_ANDROID_TYPE_BLE_SPEAKER = 27, + FF_SOUND_ANDROID_TYPE_ECHO_REFERENCE = 28, + FF_SOUND_ANDROID_TYPE_HDMI_EARC = 29, + FF_SOUND_ANDROID_TYPE_BLE_BROADCAST = 30, + FF_SOUND_ANDROID_TYPE_DOCK_ANALOG = 31, + FF_SOUND_ANDROID_TYPE_MULTICHANNEL_GROUP = 32, +} FFSoundAndroidDeviceType; + +typedef struct FFSoundAndroidDeviceName { + FFSoundAndroidDeviceType type; + const char* identifier; + const char* name; +} FFSoundAndroidDeviceName; + +static const FFSoundAndroidDeviceName FF_SOUND_ANDROID_DEVICE_NAMES[] = { + { FF_SOUND_ANDROID_TYPE_UNKNOWN, "UNKNOWN", "Unknown Device" }, + { FF_SOUND_ANDROID_TYPE_BUILTIN_EARPIECE, "BUILTIN_EARPIECE", "Built-in Earpiece" }, + { FF_SOUND_ANDROID_TYPE_BUILTIN_SPEAKER, "BUILTIN_SPEAKER", "Built-in Speaker" }, + { FF_SOUND_ANDROID_TYPE_WIRED_HEADSET, "WIRED_HEADSET", "Wired Headset" }, + { FF_SOUND_ANDROID_TYPE_WIRED_HEADPHONES, "WIRED_HEADPHONES", "Wired Headphones" }, + { FF_SOUND_ANDROID_TYPE_LINE_ANALOG, "LINE_ANALOG", "Line (Analog)" }, + { FF_SOUND_ANDROID_TYPE_LINE_DIGITAL, "LINE_DIGITAL", "Line (Digital)" }, + { FF_SOUND_ANDROID_TYPE_BLUETOOTH_SCO, "BLUETOOTH_SCO", "Bluetooth SCO" }, + { FF_SOUND_ANDROID_TYPE_BLUETOOTH_A2DP, "BLUETOOTH_A2DP", "Bluetooth A2DP" }, + { FF_SOUND_ANDROID_TYPE_HDMI, "HDMI", "HDMI" }, + { FF_SOUND_ANDROID_TYPE_HDMI_ARC, "HDMI_ARC", "HDMI ARC" }, + { FF_SOUND_ANDROID_TYPE_USB_DEVICE, "USB_DEVICE", "USB Device" }, + { FF_SOUND_ANDROID_TYPE_USB_ACCESSORY, "USB_ACCESSORY", "USB Accessory" }, + { FF_SOUND_ANDROID_TYPE_DOCK, "DOCK", "Dock" }, + { FF_SOUND_ANDROID_TYPE_FM, "FM", "FM" }, + { FF_SOUND_ANDROID_TYPE_BUILTIN_MIC, "BUILTIN_MIC", "Built-in Microphone" }, + { FF_SOUND_ANDROID_TYPE_FM_TUNER, "FM_TUNER", "FM Tuner" }, + { FF_SOUND_ANDROID_TYPE_TV_TUNER, "TV_TUNER", "TV Tuner" }, + { FF_SOUND_ANDROID_TYPE_TELEPHONY, "TELEPHONY", "Telephony" }, + { FF_SOUND_ANDROID_TYPE_AUX_LINE, "AUX_LINE", "Aux Line" }, + { FF_SOUND_ANDROID_TYPE_IP, "IP", "IP" }, + { FF_SOUND_ANDROID_TYPE_BUS, "BUS", "Bus" }, + { FF_SOUND_ANDROID_TYPE_USB_HEADSET, "USB_HEADSET", "USB Headset" }, + { FF_SOUND_ANDROID_TYPE_HEARING_AID, "HEARING_AID", "Hearing Aid" }, + { FF_SOUND_ANDROID_TYPE_BUILTIN_SPEAKER_SAFE, "BUILTIN_SPEAKER_SAFE", "Built-in Speaker (Safe)" }, + { FF_SOUND_ANDROID_TYPE_REMOTE_SUBMIX, "REMOTE_SUBMIX", "Remote Submix" }, + { FF_SOUND_ANDROID_TYPE_BLE_HEADSET, "BLE_HEADSET", "Bluetooth LE Headset" }, + { FF_SOUND_ANDROID_TYPE_BLE_SPEAKER, "BLE_SPEAKER", "Bluetooth LE Speaker" }, + { FF_SOUND_ANDROID_TYPE_ECHO_REFERENCE, "ECHO_REFERENCE", "Echo Reference" }, + { FF_SOUND_ANDROID_TYPE_HDMI_EARC, "HDMI_EARC", "HDMI eARC" }, + { FF_SOUND_ANDROID_TYPE_BLE_BROADCAST, "BLE_BROADCAST", "Bluetooth LE Broadcast" }, + { FF_SOUND_ANDROID_TYPE_DOCK_ANALOG, "DOCK_ANALOG", "Dock (Analog)" }, + { FF_SOUND_ANDROID_TYPE_MULTICHANNEL_GROUP, "MULTICHANNEL_GROUP", "Multichannel Group" }, +}; + +static const FFSoundAndroidDeviceName* soundAndroidDeviceName(int32_t type) { + for (size_t i = 0; i < ARRAY_SIZE(FF_SOUND_ANDROID_DEVICE_NAMES); ++i) { + if ((int32_t) FF_SOUND_ANDROID_DEVICE_NAMES[i].type == type) { + return &FF_SOUND_ANDROID_DEVICE_NAMES[i]; + } + } + return nullptr; +} + +// The types that stand for a Bluetooth device. They are the ones whose own name is worth asking the +// service for: everything else is described well enough by its type, and the request is one more +// round trip to system_server. A hearing aid is on the list because it is a Bluetooth LE link with +// a name of its own, the same as a headset. +static bool soundAndroidIsBluetooth(int32_t type) { + switch ((FFSoundAndroidDeviceType) type) { + case FF_SOUND_ANDROID_TYPE_BLUETOOTH_SCO: + case FF_SOUND_ANDROID_TYPE_BLUETOOTH_A2DP: + case FF_SOUND_ANDROID_TYPE_HEARING_AID: + case FF_SOUND_ANDROID_TYPE_BLE_HEADSET: + case FF_SOUND_ANDROID_TYPE_BLE_SPEAKER: + case FF_SOUND_ANDROID_TYPE_BLE_BROADCAST: + return true; + default: + return false; + } +} + +// Calls a method that takes no argument or one int, and answers with one int. Every transaction code +// is resolved here rather than passed in, so that the dex is the only place a number lives. +static const char* soundAndroidCallInt(FFBinder* binder, uint32_t handle, const char* transactionField, bool hasArgument, int32_t argument, int32_t* result) { + int32_t transaction = 0; + const char* error = ffDexStaticInt(FF_SOUND_ANDROID_JAR, FF_SOUND_ANDROID_STUB, transactionField, &transaction); + if (error != nullptr) { + FF_DEBUG("Reading \"%s\" from \"%s\" failed: %s", transactionField, FF_SOUND_ANDROID_JAR, error); + return error; + } + + uint8_t parcelBuffer[FF_SOUND_ANDROID_SCALAR_PARCEL_SIZE]; + FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer)); + ffBinderParcelPutInterfaceToken(&parcel, FF_SOUND_ANDROID_DESCRIPTOR); + if (hasArgument) { + ffBinderParcelPutI32(&parcel, argument); + } + + uint8_t replyBuffer[FF_SOUND_ANDROID_SCALAR_REPLY_SIZE]; + FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer)); + error = ffBinderTransact(binder, handle, (uint32_t) transaction, 0, &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 audio 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 audio 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; +} + +// Walks past a string16 and reports how long it was. The bytes themselves are not decoded: the only +// strings in the reply are the device address and name, both of which are empty for every built-in +// device and a partly anonymised MAC for a Bluetooth one -- `AudioService` blanks the address of +// anything it considers identifying before answering an unprivileged caller, so neither is an +// identifier worth carrying. +static bool soundAndroidSkipString16(const uint8_t* data, size_t size, size_t* offset) { + const int32_t length = ffBinderReadI32(data, size, *offset); + *offset += sizeof(int32_t); + if (length < 0) { + return true; + } + // The code units plus the NUL terminator, padded to four bytes. + const size_t payload = ((size_t) length * 2 + 2 + 3) & ~(size_t) 3; + if (*offset + payload > size) { + return false; + } + *offset += payload; + return true; +} + +// Reads a string16 out of a reply and appends it to `result`. +// +// The code units are UTF-16LE, and a device name is the first string here that is not guaranteed to +// be ASCII -- a headset can be named in any script, and `vivo TWS A4` is only the one that happened +// to be paired -- so they are converted rather than copied. A count of -1 is how the same field +// spells null, which is what the service answers when there is no name to give; that is not a +// failure, and the caller falls back to the type name. +static bool soundAndroidReadString16(const uint8_t* data, size_t size, size_t offset, FFstrbuf* result) { + if (offset + sizeof(int32_t) > size) { + return false; + } + const int32_t length = ffBinderReadI32(data, size, offset); + offset += sizeof(int32_t); + if (length < 0) { + return true; + } + // The code units plus the NUL terminator. The padding behind them is not part of the string, so + // there is nothing to step over here. + if ((size_t) length * 2 + 2 > size - offset) { + return false; + } + + for (int32_t i = 0; i < length; ++i) { + uint32_t codePoint = (uint32_t) data[offset + (size_t) i * 2] | ((uint32_t) data[offset + (size_t) i * 2 + 1] << 8); + if (codePoint >= 0xD800 && codePoint <= 0xDBFF && i + 1 < length) { + // Anything outside the basic plane arrives as a surrogate pair, and the low half is what + // turns it back into one code point. A high surrogate without one is malformed and is + // passed through as it stands rather than guessed at. + const uint32_t low = (uint32_t) data[offset + (size_t) (i + 1) * 2] | ((uint32_t) data[offset + (size_t) (i + 1) * 2 + 1] << 8); + if (low >= 0xDC00 && low <= 0xDFFF) { + codePoint = 0x10000u + ((codePoint - 0xD800u) << 10) + (low - 0xDC00u); + ++i; + } + } + ffStrbufAppendUtf32CodePoint(result, codePoint); + } + return true; +} + +// Calls `getBtActiveDeviceName()`, which takes no argument and answers a string16. It is the only +// call here that carries a human readable device name. +// +// The policy reply has a `name` field of its own, but on the test device `AudioService` leaves it +// empty for every device -- a Bluetooth one included, whose address it blanks to +// `XX:XX:XX:XX:D8:61` while the name goes with it -- so the type is all that reply can describe a +// sink by. This call is what turns "Bluetooth A2DP" into the name of the headset. +// +// What it must not be used for is deciding *which* device is the output. It answers about the +// Bluetooth route as a whole and reports it even when the policy has selected the speaker, so it is +// only ever consulted after the device list has already picked a Bluetooth sink, and only its name +// is taken from it. +static const char* soundAndroidCallBtName(FFBinder* binder, uint32_t handle, FFstrbuf* result) { + int32_t transaction = 0; + const char* error = ffDexStaticInt(FF_SOUND_ANDROID_JAR, FF_SOUND_ANDROID_STUB, FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME, &transaction); + if (error != nullptr) { + FF_DEBUG("Reading \"%s\" from \"%s\" failed: %s", FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME, FF_SOUND_ANDROID_JAR, error); + return error; + } + + uint8_t parcelBuffer[FF_SOUND_ANDROID_SCALAR_PARCEL_SIZE]; + FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer)); + ffBinderParcelPutInterfaceToken(&parcel, FF_SOUND_ANDROID_DESCRIPTOR); + + uint8_t replyBuffer[FF_SOUND_ANDROID_NAME_REPLY_SIZE]; + FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer)); + error = ffBinderTransact(binder, handle, (uint32_t) transaction, 0, &parcel, &reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(&reply)) { + FF_DEBUG("\"%s\" resolved to transaction %d, which this build does not answer", FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME, transaction); + return "The audio 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", FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME, transaction, exception); + return "The audio service raised an exception"; + } + if (!soundAndroidReadString16(reply.data, reply.size, sizeof(int32_t), result)) { + return "The audio service answered a truncated device name"; + } + FF_DEBUG("\"%s\" is transaction %d and answered \"%s\"", FF_SOUND_ANDROID_GET_BT_ACTIVE_DEVICE_NAME, transaction, + result->chars != nullptr ? result->chars : ""); + return nullptr; +} + +// Reads one `AudioDeviceAttributes` out of the reply and advances `offset` past it. +// +// The layout is the one `AudioDeviceAttributes.writeToParcel` writes, and it was checked byte by +// byte against the device's replies: +// +// int32 role, int32 type, string16 address, string16 name, int32 nativeType, +// List profiles, List descriptors +// +// `role` is 1 for a source (an input) and 2 for a sink (an output). `type` is the +// AudioDeviceInfo.TYPE_* above; `nativeType` repeats it as the older AUDIO_DEVICE_* bit value and is +// not used. Neither profile list has carried an entry on any device seen so far, so they are walked +// only far enough to confirm that. A device that does carry profiles is still reported -- everything +// this module reads comes before them -- but nothing after it in the list can be reached, because +// walking a profile means decoding one, and the caller stops there rather than reading the next +// entry out of the middle of one. +static bool soundAndroidReadDevice(const uint8_t* data, size_t size, size_t* offset, int32_t* type, int32_t* role, bool* ended) { + *ended = false; + if (*offset + 3 * sizeof(int32_t) > size) { + return false; + } + *role = ffBinderReadI32(data, size, *offset); + *offset += sizeof(int32_t); + *type = ffBinderReadI32(data, size, *offset); + *offset += sizeof(int32_t); + if (!soundAndroidSkipString16(data, size, offset) || !soundAndroidSkipString16(data, size, offset)) { + return false; + } + *offset += sizeof(int32_t); // nativeType, which follows the two strings + for (int list = 0; list < 2; ++list) { + const int32_t count = ffBinderReadI32(data, size, *offset); + *offset += sizeof(int32_t); + if (count > 0) { + // A null list is a -1 and occupies nothing beyond the count that was just read, so only + // a list with entries in it ends the walk. + *ended = true; + return true; + } + } + return true; +} + +// Turns the three volume readings into the percentage the module prints. The service answers with +// the index it keeps for the stream, which is not a percentage and is not even on a fixed scale: +// `getStreamVolume` divides by ten before returning, so the numbers here are the ones an app sees, +// and the maximum differs per stream -- 150 for music on the test device, 15 for the system stream, +// 10 for a call. Music's minimum is 0, but that is a property of the stream rather than of the API: +// a call's minimum is 1. So the percentage comes out of the range the service reports, and a stream +// whose range is empty or inverted is reported as unknown rather than as 100%. +static uint8_t soundAndroidPercent(int32_t volume, int32_t minimum, int32_t maximum, int32_t mute) { + if (mute != 0) { + // A muted stream is reported as 0 rather than as its index: the service keeps the index and + // the mute flag separately, and it is the mute flag that decides what comes out of the + // speaker. This is also what the PulseAudio backend does with a muted sink. + return 0; + } + if (maximum <= minimum) { + return FF_SOUND_VOLUME_UNKNOWN; + } + if (volume <= minimum) { + return 0; + } + if (volume >= maximum) { + return 100; + } + return (uint8_t) (((volume - minimum) * 100 + (maximum - minimum) / 2) / (maximum - minimum)); +} + +static const char* detectNative(FFSoundOptions* options, FFlist* devices) { + // Resolved before anything else so that a build without the method says so, rather than opening + // binder and failing later with a message about the transport. + int32_t transaction = 0; + const char* error = ffDexStaticInt(FF_SOUND_ANDROID_JAR, FF_SOUND_ANDROID_STUB, FF_SOUND_ANDROID_GET_DEVICES_FOR_ATTRIBUTES, &transaction); + if (error != nullptr) { + FF_DEBUG("Reading \"%s\" from \"%s\" failed: %s", FF_SOUND_ANDROID_GET_DEVICES_FOR_ATTRIBUTES, FF_SOUND_ANDROID_JAR, error); + return "The audio service does not declare getDevicesForAttributesUnprotected"; + } + + [[gnu::cleanup(ffBinderClose)]] FFBinder binder = { .fd = -1 }; + error = ffBinderOpen(&binder); + if (error != nullptr) { + return error; + } + + // Released on every path out, including the early returns below. + [[gnu::cleanup(ffBinderServiceHandleRelease)]] FFBinderServiceHandle service = { .binder = &binder }; + error = ffBinderLookupService(&binder, FF_SOUND_ANDROID_SERVICE, FF_BINDER_SM_GET_SERVICE, &service.handle); + if (error != nullptr) { + return error; + } + FF_DEBUG("The \"%s\" service is handle %u, getDevicesForAttributesUnprotected is transaction %d", + FF_SOUND_ANDROID_SERVICE, service.handle, transaction); + + uint8_t parcelBuffer[FF_SOUND_ANDROID_DEVICE_PARCEL_SIZE]; + FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer)); + ffBinderParcelPutInterfaceToken(&parcel, FF_SOUND_ANDROID_DESCRIPTOR); + // The non-null marker `Parcel.writeTypedObject` puts in front of a Parcelable. The service reads + // the argument with `readTypedObject`, which reads that int first and hands back a null object + // when it is not 1 -- so leaving it out does not shift the fields, it loses the argument, and + // the service answers with an exception rather than a device list. + ffBinderParcelPutI32(&parcel, 1); + // `in AudioAttributes`, whose writeToParcel is: usage, contentType, source, flags, the parcel + // flags, the tag array and the bundle marker. A null bundle is the only form built here. + ffBinderParcelPutI32(&parcel, (int32_t) FF_SOUND_ANDROID_USAGE_MEDIA); + ffBinderParcelPutI32(&parcel, 0); // contentType, unused by the policy when a usage is set + ffBinderParcelPutI32(&parcel, FF_SOUND_ANDROID_SOURCE_INVALID); + ffBinderParcelPutI32(&parcel, 0); // flags + ffBinderParcelPutI32(&parcel, 0); // parcel flags: the tags are not flattened + ffBinderParcelPutI32(&parcel, 0); // an empty tag array + ffBinderParcelPutI32(&parcel, FF_SOUND_ANDROID_ATTR_NULL_BUNDLE); + + uint8_t replyBuffer[FF_SOUND_ANDROID_DEVICE_REPLY_SIZE]; + FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer)); + error = ffBinderTransact(&binder, service.handle, (uint32_t) transaction, 0, &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 "The audio service rejected the request"; + } + + const int32_t exception = ffBinderReadI32(reply.data, reply.size, 0); + if (exception != 0) { + FF_DEBUG("getDevicesForAttributesUnprotected raised exception %d", exception); + return "The audio service raised an exception"; + } + + // The service answers with the devices the policy has selected for that usage, so there can be + // more than one -- an alarm or a ringtone plays on the speaker and on a connected headset at the + // same time -- and the first is the one the stream's volume belongs to. + const int32_t count = ffBinderReadI32(reply.data, reply.size, 4); + size_t offset = 2 * sizeof(int32_t); + const FFSoundAndroidDeviceName* name = nullptr; + int32_t deviceType = 0; + for (int32_t index = 0; index < count; ++index) { + const int32_t present = ffBinderReadI32(reply.data, reply.size, offset); + offset += sizeof(int32_t); + if (present != 1) { + continue; + } + + int32_t type = 0; + int32_t role = 0; + bool ended = false; + if (!soundAndroidReadDevice(reply.data, reply.size, &offset, &type, &role, &ended)) { + FF_DEBUG("The device list ended after %d of %d entries", index, count); + break; + } + + if (role == 2 && name == nullptr) { + // ROLE_SINK. A source would be a microphone, which the policy only answers when the + // request asks for one, so this is a guard rather than a case that happens. + name = soundAndroidDeviceName(type); + deviceType = type; + FF_DEBUG("Media output device %d of %d is type %d, which is \"%s\"", + index + 1, count, type, name != nullptr ? name->identifier : "an unrecognised type"); + } + + if (ended) { + // A profile list with entries in it: the next entry cannot be reached without walking + // one, and the first device is the one that matters. + FF_DEBUG("Device %d carries audio profiles, so the rest of the list is not read", index); + break; + } + } + + if (name == nullptr) { + return "The audio service selected no output device"; + } + + int32_t volume = 0; + int32_t minimum = 0; + int32_t maximum = 0; + int32_t mute = 0; + error = soundAndroidCallInt(&binder, service.handle, FF_SOUND_ANDROID_GET_STREAM_VOLUME, true, (int32_t) FF_SOUND_ANDROID_STREAM_MUSIC, &volume); + if (error == nullptr) { + error = soundAndroidCallInt(&binder, service.handle, FF_SOUND_ANDROID_GET_STREAM_MIN_VOLUME, true, (int32_t) FF_SOUND_ANDROID_STREAM_MUSIC, &minimum); + } + if (error == nullptr) { + error = soundAndroidCallInt(&binder, service.handle, FF_SOUND_ANDROID_GET_STREAM_MAX_VOLUME, true, (int32_t) FF_SOUND_ANDROID_STREAM_MUSIC, &maximum); + } + if (error == nullptr) { + error = soundAndroidCallInt(&binder, service.handle, FF_SOUND_ANDROID_IS_STREAM_MUTE, true, (int32_t) FF_SOUND_ANDROID_STREAM_MUSIC, &mute); + } + if (error != nullptr) { + return error; + } + + uint8_t percent = soundAndroidPercent(volume, minimum, maximum, mute); + FF_DEBUG("Stream %d is %d of %d..%d, muted %d, which is %s", + (int) FF_SOUND_ANDROID_STREAM_MUSIC, volume, minimum, maximum, mute, + percent != FF_SOUND_VOLUME_UNKNOWN ? "a percentage" : "unknown"); + + // `isMusicActive` is what tells "this device is the output" apart from "this device is playing". + // It answers about the media stream as a whole and not about one device, so it only ever adds + // the ACTIVE bit to the device the policy already selected. A build that does not declare the + // method, or a service that refuses it, leaves the bit clear rather than failing the module. + int32_t active = 0; + const char* activeError = soundAndroidCallInt(&binder, service.handle, FF_SOUND_ANDROID_IS_MUSIC_ACTIVE, true, 0, &active); + if (activeError != nullptr) { + FF_DEBUG("Whether anything is playing is not known: %s", activeError); + active = 0; + } + + const FFSoundType type = (FFSoundType) (FF_SOUND_TYPE_MAIN | (active != 0 ? FF_SOUND_TYPE_ACTIVE : FF_SOUND_TYPE_NONE)); + if ((options->soundType & FF_SOUND_TYPE_MAIN) && !(type & FF_SOUND_TYPE_MAIN)) { + return nullptr; + } + if ((options->soundType & FF_SOUND_TYPE_ACTIVE) && !(type & FF_SOUND_TYPE_ACTIVE)) { + return nullptr; + } + + // A Bluetooth sink is reported by the name of the device behind it when the service can be + // asked, and by its type otherwise. Falling back is not an error path: a build that does not + // declare the method, a service that refuses it, and a service that has no name to give all end + // in the type name, which is what this module printed before it asked at all. + FF_STRBUF_AUTO_DESTROY bluetoothName = ffStrbufCreate(); + if (soundAndroidIsBluetooth(deviceType)) { + const char* nameError = soundAndroidCallBtName(&binder, service.handle, &bluetoothName); + if (nameError != nullptr || bluetoothName.length == 0) { + FF_DEBUG("The Bluetooth device is not named: %s", + nameError != nullptr ? nameError : "the service answered an empty name"); + } + } + + FFSoundDevice* device = FF_LIST_ADD(FFSoundDevice, *devices); + // The identifier stays the type: it is the machine readable half of the pair, and the Bluetooth + // address the reply carries is not usable as one -- `AudioService` blanks it to + // `XX:XX:XX:XX:D8:61` for an unprivileged caller. + ffStrbufInitS(&device->identifier, name->identifier); + if (bluetoothName.length > 0) { + ffStrbufInitMove(&device->name, &bluetoothName); + } else { + ffStrbufInitS(&device->name, name->name); + } + ffStrbufInitStatic(&device->platformApi, "AudioService"); + device->volume = percent; + device->type = type; + FF_DEBUG("Reporting \"%s\" (%s) at %u%%%s", device->name.chars, device->identifier.chars, device->volume, + active != 0 ? ", active" : ""); + return nullptr; +} + +const char* ffDetectSound(FFSoundOptions* options, FFlist* devices) { + const FFDisplayServerResult* wm = ffConnectDisplayServer(); + if (!ffStrbufIgnCaseEqualS(&wm->wmProtocolName, FF_WM_PROTOCOL_SURFACEFLINGER)) { + return nullptr; + } +}