diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a663aaa..05f90e155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Features: * Added Sound detection on Android. (Sound, Android) * Added display detection on Android over binder, which needs neither a child process nor a permission. (Display, Android) * Added battery temperature, remaining time and charger type (AC / USB / wireless) detection on Android. (Battery, Android) +* Added media detection on Android when fastfetch runs as an app UID. (Media, Android) + * Requires `Notification access` permission * Performance improvements & internal cleanups * Added Finnish language support. (General) * Added pkgsrc package detection on macOS. (#2627, Packages, Apple) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8be5030a5..1c218ca01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -678,9 +678,11 @@ if(LINUX) ) elseif(ANDROID) list(APPEND LIBFASTFETCH_SRC + src/common/android/applabel.c src/common/android/binder.c src/common/android/dex.c src/common/android/package.c + src/common/android/zip.c src/common/impl/dbus.c src/common/impl/io_unix.c src/common/impl/netif_linux.c diff --git a/src/common/android/applabel.c b/src/common/android/applabel.c new file mode 100644 index 000000000..848c78eb1 --- /dev/null +++ b/src/common/android/applabel.c @@ -0,0 +1,560 @@ +#include "common/android/applabel.h" +#include "common/android/binder.h" +#include "common/android/dex.h" +#include "common/android/zip.h" +#include "common/debug.h" + +#include +#include +#include +#include +#include +#include +#include + +// The package manager, and the one method that describes an app to a caller that is not the shell. +// IPackageManager 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. +#define FF_ANDROID_LABEL_SERVICE "package" +#define FF_ANDROID_LABEL_DESCRIPTOR "android.content.pm.IPackageManager" +#define FF_ANDROID_LABEL_JAR "/system/framework/framework.jar" +#define FF_ANDROID_LABEL_STUB "Landroid/content/pm/IPackageManager$Stub;" +#define FF_ANDROID_LABEL_TRANSACTION "TRANSACTION_getApplicationInfo" + +#define FF_ANDROID_LABEL_TABLE "resources.arsc" + +// The longest package name AIDL accepts is 255 characters, and the request is one string16 of it, the +// interface token, a 64-bit flags argument and a 32-bit user id. +// clang-format off +#define FF_ANDROID_LABEL_REQUEST_SIZE \ + (12 + ((4 + 2 * (sizeof(FF_ANDROID_LABEL_DESCRIPTOR) - 1) + 3) & ~3u) \ + + ((4 + 2 * 255 + 3) & ~3u) + 8u + 4u) +// clang-format on + +// An ApplicationInfo parcel runs 2.0 to 3.2 KB on the test device, and the surplus is what a reply +// with more split names and directories than any of those would spend. +#define FF_ANDROID_LABEL_REPLY_SIZE (16 * 1024) + +// Long enough for the `/data/app/~~<22 base64 chars>==/-<22 chars>==/base.apk` the platform +// installs to, with room to spare. +#define FF_ANDROID_LABEL_PATH_SIZE 512 + +// --------------------------------------------------------------------------------------------- +// The resource table +// --------------------------------------------------------------------------------------------- +// +// `resources.arsc` is a ResTable: a header, a global string pool, and then one ResTable_package per +// package it defines, each carrying its own pools for type names and entry keys followed by the +// types themselves. Resolving `labelRes` is two indices deep -- the package's number names the +// package and the type, the low half names the entry -- and every step below reads the file rather +// than a table of what the file is expected to hold, because the layout is the platform's and the +// numbers are per APK. + +// ResChunk_header: type(2) headerSize(2) size(4). Every chunk in the file starts with one. +#define FF_ARSC_TABLE_TYPE 0x0002 +#define FF_ARSC_STRING_POOL_TYPE 0x0001 +#define FF_ARSC_TABLE_PACKAGE_TYPE 0x0200 +#define FF_ARSC_TABLE_TYPE_TYPE 0x0201 + +// ResStringPool: header(8) stringCount(4) styleCount(4) flags(4) stringsStart(4) stylesStart(4) +#define FF_ARSC_POOL_HEADER_SIZE 28 +#define FF_ARSC_OFF_POOL_CHUNK_SIZE 4 +#define FF_ARSC_OFF_POOL_COUNT 8 +#define FF_ARSC_OFF_POOL_FLAGS 16 +#define FF_ARSC_OFF_POOL_STRINGS_START 20 + +// A UTF-8 pool says so with this bit, and then holds the strings as UTF-8 rather than as UTF-16. +#define FF_ARSC_POOL_FLAG_UTF8 0x0100 + +// ResTable_package: header(8) id(4) name(256, a UTF-16 array) typeStrings(4) lastPublicType(4) ... +#define FF_ARSC_OFF_PACKAGE_ID 8 +#define FF_ARSC_OFF_PACKAGE_TYPE_STRINGS (8 + 4 + 256) + +// ResTable_type: header(8) id(1) flags(1) reserved(2) entryCount(4) entriesStart(4) config(...) +#define FF_ARSC_OFF_TYPE_ID 8 +#define FF_ARSC_OFF_TYPE_FLAGS 9 +#define FF_ARSC_OFF_TYPE_ENTRY_COUNT 12 +#define FF_ARSC_OFF_TYPE_ENTRIES_START 16 +#define FF_ARSC_OFF_TYPE_CONFIG 20 + +// The two shapes a type can take that this does not read: a sparse type stores its offsets as +// index-offset pairs rather than as a flat table, and a 16-bit one stores them at half width. Neither +// is in the APKs measured, and both would resolve an entry to the wrong offset rather than to none, +// so they are refused rather than read as if they were the plain shape. +#define FF_ARSC_TYPE_FLAG_SPARSE 0x01 +#define FF_ARSC_TYPE_FLAG_OFFSET16 0x02 + +// ResTable_entry: size(2) flags(2), where size is the header's own length and the value follows it. +#define FF_ARSC_ENTRY_FLAG_COMPLEX 0x0001 + +// Res_value: size(2) res0(1) dataType(1) data(4). TYPE_STRING makes data an index into the global +// string pool; every other type is either a number or a reference to another resource. +#define FF_ARSC_OFF_VALUE_DATA_TYPE 3 +#define FF_ARSC_OFF_VALUE_DATA 4 +#define FF_ARSC_TYPE_STRING 0x03 + +// A type chunk's offset table has one word per entry, and this is what an entry with no value in that +// configuration looks like. It is not zero: offset zero is a real entry, at the start of the block. +#define FF_ARSC_NO_ENTRY 0xffffffffu + +// The type a label resolves to. `string` is what `@string/...` compiles to, and it is what tells a +// label from the icons and banners that sit beside it in the same reply -- those point at `mipmap` +// or `drawable` entries whose value is a path inside the APK, so resolving them would hand back +// `res/9w.png` instead of a name. +#define FF_ANDROID_LABEL_STRING_TYPE "string" + +static uint16_t readU16(const uint8_t* p) { + return (uint16_t) ((uint16_t) p[0] | (uint16_t) ((uint16_t) p[1] << 8)); +} + +static uint32_t readU32(const uint8_t* p) { + return (uint32_t) p[0] | ((uint32_t) p[1] << 8) | ((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24); +} + +// One ResStringPool, of which a table has three. +typedef struct FFAndroidLabelPool { + const uint8_t* table; + size_t tableSize; + size_t offset; + uint32_t chunkSize; + uint32_t count; + uint32_t headerSize; + uint32_t stringsStart; + bool utf8; +} FFAndroidLabelPool; + +[[gnu::nonnull(2), nodiscard]] static bool poolInit(FFAndroidLabelPool* pool, const uint8_t* table, size_t tableSize, size_t offset) { + if (offset > tableSize || tableSize - offset < FF_ARSC_POOL_HEADER_SIZE) { + return false; + } + if (readU16(table + offset) != FF_ARSC_STRING_POOL_TYPE) { + return false; + } + pool->table = table; + pool->tableSize = tableSize; + pool->offset = offset; + pool->chunkSize = readU32(table + offset + FF_ARSC_OFF_POOL_CHUNK_SIZE); + pool->count = readU32(table + offset + FF_ARSC_OFF_POOL_COUNT); + pool->headerSize = readU16(table + offset + 2); + pool->stringsStart = readU32(table + offset + FF_ARSC_OFF_POOL_STRINGS_START); + pool->utf8 = (readU32(table + offset + FF_ARSC_OFF_POOL_FLAGS) & FF_ARSC_POOL_FLAG_UTF8) != 0; + return pool->headerSize >= FF_ARSC_POOL_HEADER_SIZE && pool->chunkSize > pool->headerSize; +} + +// The length in front of a string. A UTF-8 pool writes one or two bytes, a UTF-16 pool one or two +// 16-bit words, and the top bit of the first says which of the two it is. +[[gnu::nonnull(1, 3), nodiscard]] static bool readLength8(const FFAndroidLabelPool* pool, size_t* position, uint32_t* length) { + if (*position >= pool->tableSize) { + return false; + } + uint32_t value = pool->table[(*position)++]; + if (value & 0x80) { + if (*position >= pool->tableSize) { + return false; + } + value = ((value & 0x7F) << 8) | pool->table[(*position)++]; + } + *length = value; + return true; +} + +[[gnu::nonnull(1, 3), nodiscard]] static bool readLength16(const FFAndroidLabelPool* pool, size_t* position, uint32_t* length) { + if (*position + 2 > pool->tableSize) { + return false; + } + uint32_t value = readU16(pool->table + *position); + *position += 2; + if (value & 0x8000) { + if (*position + 2 > pool->tableSize) { + return false; + } + value = ((value & 0x7FFF) << 16) | readU16(pool->table + *position); + *position += 2; + } + *length = value; + return true; +} + +[[gnu::nonnull(1)]] static void appendCodePoint(FFstrbuf* result, uint32_t code) { + char encoded[4] = {}; + uint32_t length = 0; + if (code < 0x80) { + encoded[length++] = (char) code; + } else if (code < 0x800) { + encoded[length++] = (char) (0xC0 | (code >> 6)); + encoded[length++] = (char) (0x80 | (code & 0x3F)); + } else if (code < 0x10000) { + encoded[length++] = (char) (0xE0 | (code >> 12)); + encoded[length++] = (char) (0x80 | ((code >> 6) & 0x3F)); + encoded[length++] = (char) (0x80 | (code & 0x3F)); + } else { + encoded[length++] = (char) (0xF0 | (code >> 18)); + encoded[length++] = (char) (0x80 | ((code >> 12) & 0x3F)); + encoded[length++] = (char) (0x80 | ((code >> 6) & 0x3F)); + encoded[length++] = (char) (0x80 | (code & 0x3F)); + } + ffStrbufAppendNS(result, length, encoded); +} + +// One string out of a pool. +// +// The two lengths in front of a UTF-8 string are a *character* count and a *byte* count, and they +// differ for everything outside ASCII. Decoding `characters` bytes is the trap: a label of a few +// multi-byte characters comes back as its first character followed by replacement bytes. Measured on +// the test device, where every resource table read is a UTF-8 pool, this was the difference between +// the right label and mojibake. +[[gnu::nonnull(1, 3), nodiscard]] static bool poolGet(const FFAndroidLabelPool* pool, uint32_t index, FFstrbuf* result) { + if (index >= pool->count) { + return false; + } + const size_t offsets = pool->offset + pool->headerSize; + if (offsets + ((size_t) index + 1) * 4 > pool->tableSize) { + return false; + } + size_t position = pool->offset + pool->stringsStart + readU32(pool->table + offsets + (size_t) index * 4); + if (position >= pool->tableSize) { + return false; + } + + if (pool->utf8) { + uint32_t characters = 0; + uint32_t bytes = 0; + if (!readLength8(pool, &position, &characters) || !readLength8(pool, &position, &bytes)) { + return false; + } + // An empty string is reported as a miss rather than written: the caller sets its fallback + // before asking, and ffStrbufSetNS() with a length of zero *clears* the buffer, which would + // throw that fallback away instead of leaving it alone. + if (bytes == 0 || bytes > pool->tableSize - position) { + return false; + } + ffStrbufSetNS(result, bytes, (const char*) (pool->table + position)); + return true; + } + + uint32_t units = 0; + if (!readLength16(pool, &position, &units)) { + return false; + } + if (units == 0 || (size_t) units * 2 > pool->tableSize - position) { + return false; + } + for (uint32_t i = 0; i < units; i++) { + uint32_t code = readU16(pool->table + position + (size_t) i * 2); + // A code point outside the basic plane arrives as a surrogate pair, and the two halves are + // not characters of their own: half of a pair encoded on its own is not valid UTF-8. + if (code >= 0xD800 && code <= 0xDBFF && i + 1 < units) { + const uint32_t low = readU16(pool->table + position + ((size_t) i + 1) * 2); + if (low >= 0xDC00 && low <= 0xDFFF) { + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + i++; + } + } + appendCodePoint(result, code); + } + return true; +} + +// The one ResTable_package this table defines, with the two pools a lookup starts from. +typedef struct FFAndroidLabelTable { + const uint8_t* data; + size_t size; + FFAndroidLabelPool values; + FFAndroidLabelPool types; + size_t packageOffset; + size_t packageHeaderSize; + size_t packageSize; + uint32_t packageId; +} FFAndroidLabelTable; + +[[gnu::nonnull(1), nodiscard]] static bool tableInit(FFAndroidLabelTable* table, const uint8_t* data, size_t size) { + // ResTable_header: header(8) packageCount(4). The header's own size is where the global pool + // starts, which is 12 in every table measured -- but it is the field rather than the number. + if (size < 12 || readU16(data) != FF_ARSC_TABLE_TYPE) { + return false; + } + table->data = data; + table->size = size; + const uint32_t headerSize = readU16(data + 2); + if (!poolInit(&table->values, data, size, headerSize)) { + return false; + } + + size_t position = (size_t) headerSize + table->values.chunkSize; + while (position + 8 <= size) { + const uint16_t type = readU16(data + position); + const uint16_t chunkHeader = readU16(data + position + 2); + const uint32_t chunkSize = readU32(data + position + 4); + if (chunkSize < 8 || chunkSize > size - position) { + break; + } + // A table can define several packages -- an app that links a shared library's resources has + // more than one -- and the id in the resource reference is what picks the one to read. + if (type == FF_ARSC_TABLE_PACKAGE_TYPE && chunkSize >= chunkHeader && chunkHeader >= FF_ARSC_OFF_PACKAGE_TYPE_STRINGS + 4) { + table->packageOffset = position; + table->packageHeaderSize = chunkHeader; + table->packageSize = chunkSize; + table->packageId = readU32(data + position + FF_ARSC_OFF_PACKAGE_ID); + if (!poolInit(&table->types, data, size, position + readU32(data + position + FF_ARSC_OFF_PACKAGE_TYPE_STRINGS))) { + return false; + } + return true; + } + position += chunkSize; + } + return false; +} + +// A type chunk whose ResTable_config is all zeroes is the one that applies when nothing about the +// device or the locale matches a more specific chunk. An app with a translation has several chunks +// for one type, and picking the first one that happens along would answer with whatever language it +// was written in. +[[gnu::nonnull(1), nodiscard]] static bool isDefaultConfig(const uint8_t* config, size_t length) { + if (length == 0) { + return false; + } + for (size_t i = 0; i < length; i++) { + if (config[i] != 0) { + return false; + } + } + return true; +} + +[[gnu::nonnull(1, 5), nodiscard]] static bool resolveIn(const FFAndroidLabelTable* table, uint32_t typeId, uint32_t entryId, bool defaultOnly, FFstrbuf* result) { + const size_t end = table->packageOffset + table->packageSize; + for (size_t position = table->packageOffset + table->packageHeaderSize; position + 8 <= end;) { + const uint16_t type = readU16(table->data + position); + const uint16_t header = readU16(table->data + position + 2); + const uint32_t size = readU32(table->data + position + 4); + if (size < 8 || size > end - position) { + return false; + } + if (type == FF_ARSC_TABLE_TYPE_TYPE && header >= FF_ARSC_OFF_TYPE_CONFIG) { + const uint8_t id = table->data[position + FF_ARSC_OFF_TYPE_ID]; + const uint8_t flags = table->data[position + FF_ARSC_OFF_TYPE_FLAGS]; + if (id == typeId && (flags & (FF_ARSC_TYPE_FLAG_SPARSE | FF_ARSC_TYPE_FLAG_OFFSET16)) == 0 && size >= header) { + const uint32_t entryCount = readU32(table->data + position + FF_ARSC_OFF_TYPE_ENTRY_COUNT); + const uint32_t entriesStart = readU32(table->data + position + FF_ARSC_OFF_TYPE_ENTRIES_START); + const size_t offsets = position + header; + if (entryId < entryCount && offsets + ((size_t) entryId + 1) * 4 <= end) { + if (defaultOnly && !isDefaultConfig(table->data + position + FF_ARSC_OFF_TYPE_CONFIG, (size_t) header - FF_ARSC_OFF_TYPE_CONFIG)) { + position += size; + continue; + } + const uint32_t offset = readU32(table->data + offsets + (size_t) entryId * 4); + // `entriesStart` is relative to the chunk, not to the package, and both it and + // the entry's own offset come out of the file. + if (offset != FF_ARSC_NO_ENTRY && entriesStart <= size && offset <= size - entriesStart) { + const size_t entry = position + entriesStart + offset; + const size_t value = entry + readU16(table->data + entry); + if (value >= entry && value + 8 <= end && (readU16(table->data + entry + 2) & FF_ARSC_ENTRY_FLAG_COMPLEX) == 0 + && table->data[value + FF_ARSC_OFF_VALUE_DATA_TYPE] == FF_ARSC_TYPE_STRING) { + return poolGet(&table->values, readU32(table->data + value + FF_ARSC_OFF_VALUE_DATA), result); + } + } + } + } + } + position += size; + } + return false; +} + +// Resolves one resource id to its string, and only when the type it names is a `string`. +// +// The type name is the whole reason this reads the package's type pool rather than trusting the id: +// `labelRes` and the icon, logo and banner ids that sit beside it in the same reply are all resource +// ids of the same package, and the difference between them is the type they point at. A `mipmap` id +// resolves perfectly well -- to `res/9w.png`. +[[gnu::nonnull(1, 3), nodiscard]] static bool tableResolve(const FFAndroidLabelTable* table, uint32_t resourceId, FFstrbuf* result) { + const uint32_t typeId = (resourceId >> 16) & 0xFF; + if (((resourceId >> 24) & 0xFF) != table->packageId || typeId == 0) { + return false; + } + + FFstrbuf typeName; + ffStrbufInit(&typeName); + const bool isString = poolGet(&table->types, typeId - 1, &typeName) && ffStrbufEqualS(&typeName, FF_ANDROID_LABEL_STRING_TYPE); + ffStrbufDestroy(&typeName); + if (!isString) { + return false; + } + + // The configuration that applies is the unqualified one; a chunk that only applies to a locale + // or a density is the fallback rather than the answer, which is what the second pass is for. + return resolveIn(table, typeId, resourceId & 0xFFFF, true, result) + || resolveIn(table, typeId, resourceId & 0xFFFF, false, result); +} + +// --------------------------------------------------------------------------------------------- +// The reply +// --------------------------------------------------------------------------------------------- + +// The reply's own copy of the APK path. `sourceDir` is a string in the ApplicationInfo parcel and it +// ends in `.apk`; nothing else in the reply does, which is what makes it findable without walking +// the parcel's field order -- that would mean recovering `ApplicationInfo.writeToParcel` out of the +// device's dex and then framing sixty fields of unknown width, to reach one string that is already +// recognizable. +[[gnu::nonnull(1, 3), nodiscard]] static bool findApkPath(const uint8_t* data, size_t size, char* result, size_t capacity) { + for (size_t i = 8; i < size; i++) { + if (data[i] != '/') { + continue; + } + size_t end = i; + while (end < size && data[end] >= 0x20 && data[end] < 0x7F) { + end++; + } + const size_t length = end - i; + if (length >= 5 && length < capacity && memcmp(data + end - 4, ".apk", 4) == 0) { + memcpy(result, data + i, length); + result[length] = '\0'; + return true; + } + i = end; + } + return false; +} + +// --------------------------------------------------------------------------------------------- + +typedef struct FFAndroidLabelMapping { + const uint8_t* data; + size_t size; +} FFAndroidLabelMapping; + +[[gnu::nonnull(1)]] static void labelUnmap(FFAndroidLabelMapping* mapping) { + if (mapping->data != nullptr) { + munmap((void*) mapping->data, mapping->size); + } +} + +[[gnu::nonnull(2), nodiscard]] static const char* labelMap(const char* path, FFAndroidLabelMapping* mapping) { + const int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + FF_DEBUG("open(%s) failed: %s", path, strerror(errno)); + return "open(apk) failed"; + } + + struct stat status = {}; + const bool failed = fstat(fd, &status) != 0; + if (failed || status.st_size <= 0) { + FF_DEBUG("fstat(%s) reported %s", path, failed ? strerror(errno) : "an empty file"); + close(fd); + return "fstat(apk) failed"; + } + + void* mapped = mmap(nullptr, (size_t) status.st_size, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (mapped == MAP_FAILED) { + FF_DEBUG("mmap(%s, %lld bytes) failed: %s", path, (long long) status.st_size, strerror(errno)); + return "mmap(apk) failed"; + } + mapping->data = mapped; + mapping->size = (size_t) status.st_size; + return nullptr; +} + +// One reply buffer for every call this file makes, and it makes one per query. +static uint8_t s_reply[FF_ANDROID_LABEL_REPLY_SIZE]; + +const char* ffAndroidGetAppLabel(const char* packageName, FFstrbuf* result) { + // The transaction code comes out of the jar rather than from a table here: it is a property of + // the `.aidl` the device's ROM was built from, and a vendor that inserts a method ahead of this + // one shifts every code behind it. See common/android/dex.h. + int32_t transaction = FF_DEX_STATIC_INT_UNRESOLVED; + const FFDexStaticIntRequest lookup = { + .classDescriptor = FF_ANDROID_LABEL_STUB, + .fieldName = FF_ANDROID_LABEL_TRANSACTION, + .result = &transaction, + }; + const char* error = ffDexStaticInts(FF_ANDROID_LABEL_JAR, &lookup, 1); + if (error != nullptr) { + return error; + } + if (transaction == FF_DEX_STATIC_INT_UNRESOLVED) { + return "framework.jar does not declare IPackageManager.getApplicationInfo"; + } + + [[gnu::cleanup(ffBinderClose)]] FFBinder binder = { .fd = -1 }; + error = ffBinderOpen(&binder); + if (error != nullptr) { + return error; + } + + [[gnu::cleanup(ffBinderServiceHandleRelease)]] FFBinderServiceHandle service = { .binder = &binder }; + error = ffBinderLookupService(&binder, FF_ANDROID_LABEL_SERVICE, FF_BINDER_SM_GET_SERVICE, &service.handle); + if (error != nullptr) { + FF_DEBUG("Looking up \"%s\" failed: %s", FF_ANDROID_LABEL_SERVICE, error); + return error; + } + + uint8_t request[FF_ANDROID_LABEL_REQUEST_SIZE]; + FFBinderParcel parcel = ffBinderParcelCreate(request, sizeof(request)); + ffBinderParcelPutInterfaceToken(&parcel, FF_ANDROID_LABEL_DESCRIPTOR); + ffBinderParcelPutString16(&parcel, packageName); + // `getApplicationInfo(String, long flags, int userId)`. The flags argument is 64-bit from Android + // 13 on and 32-bit before it, and the wider one is what works on both: a release that reads an + // int takes the low word and leaves the zero high word unread, because an AIDL stub stops + // reading once its own arguments are in. The narrow one is not symmetric -- a release that reads + // a long would take half of its own flags argument for the user id. + ffBinderParcelPutU64(&parcel, 0); + ffBinderParcelPutI32(&parcel, 0); + + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + error = ffBinderTransact(&binder, service.handle, (uint32_t) transaction, 0, &parcel, &reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(&reply)) { + return "The package manager rejected the request"; + } + // The AIDL status first, then the presence marker of a nullable Parcelable: a package the caller + // is not allowed to see answers an empty ApplicationInfo rather than an error. + if (ffBinderReadI32(reply.data, reply.size, 0) != 0) { + return "The package manager refused to describe the app"; + } + if (ffBinderReadI32(reply.data, reply.size, 4) == 0) { + FF_DEBUG("The package manager described \"%s\" as an empty ApplicationInfo", packageName); + return nullptr; + } + + char path[FF_ANDROID_LABEL_PATH_SIZE]; + if (!findApkPath(reply.data, reply.size, path, sizeof(path))) { + return "The reply carries no APK path"; + } + FF_DEBUG("\"%s\" is installed at \"%s\"", packageName, path); + + [[gnu::cleanup(labelUnmap)]] FFAndroidLabelMapping mapping = {}; + error = labelMap(path, &mapping); + if (error != nullptr) { + return error; + } + + FFAndroidZipEntry table = {}; + error = ffAndroidZipFindEntry(mapping.data, mapping.size, FF_ANDROID_LABEL_TABLE, &table); + if (error != nullptr) { + return error; + } + if (table.method != FF_ZIP_METHOD_STORED) { + FF_DEBUG("\"%s\" stores resources.arsc with compression method %u", packageName, (unsigned) table.method); + return "The resource table is stored compressed"; + } + + FFAndroidLabelTable resources = {}; + if (!tableInit(&resources, table.payload, table.payloadSize)) { + return "The resource table could not be read"; + } + + // Every word of the reply shaped like one of this package's resource ids, in the order they + // appear, and the first one that resolves to a `string` is the label. See the header for what + // that rule was audited against and why it beats recovering the parcel's field order. + for (size_t offset = 8; offset + 4 <= reply.size; offset += 4) { + if (tableResolve(&resources, readU32(reply.data + offset), result)) { + FF_DEBUG("\"%s\" shows itself as \"%s\"", packageName, result->chars); + return nullptr; + } + } + FF_DEBUG("No resource id in the reply of \"%s\" resolves to a string", packageName); + return nullptr; +} diff --git a/src/common/android/applabel.h b/src/common/android/applabel.h new file mode 100644 index 000000000..c0a76e4f6 --- /dev/null +++ b/src/common/android/applabel.h @@ -0,0 +1,37 @@ +#pragma once + +#include "fastfetch.h" + +// The name an app presents to the user, which is not its package name: an app installed as +// `com.termux` presents itself as `Termux:Launcher` on the launcher, in its notifications and on +// the settings screen. +// +// Every route that answers with an *already resolved* label is closed to an app UID, and each one +// was measured on the test device rather than assumed: +// +// * `dumpsys package` needs android.permission.DUMP. +// * `cmd package dump` is filtered down to 929 bytes for an app UID, against 1.2 MB for the shell. +// * The task descriptions the activity service hands out only ever describe the caller's own task. +// * `getApplicationInfo` answers with `labelRes` -- a resource id -- and leaves `nonLocalizedLabel` +// null, so there is no string in the reply to read. +// +// What is open is the APK itself. `getApplicationInfo` carries its path (`sourceDir`), APKs are +// world readable, and the `resources.arsc` inside one is *stored* rather than deflated -- the +// platform expects to mmap it -- so resolving an id out of it needs no decompressor. That is the +// route this takes: one transaction, one mapping, no fork and no child process. +// +// The label is the resource id in the reply whose type is `string`. That is a rule about the reply +// rather than about the parcel's field order, and it was audited against the manifest's own +// `android:label` over every package installed on the test device: nine in ten have exactly one +// `string` candidate and it is the right one every time, a handful more have two and the first is +// still the right one, and the rest have none because their label is a literal string rather than a +// reference (`android:label="Example"`), so the reply carries no id at all. No package resolved to +// a wrong string; the counts behind that are in doc/app-label.md. +// +// Writes the label into `result` when it can be resolved, and leaves `result` alone otherwise -- +// which includes the cases above where there is nothing to resolve. The caller sets its fallback +// first, so that an unresolved label costs nothing but the query. +// +// Returns nullptr on success, a static message when the query itself could not be made. An error is +// not a fallback: it says the label was not looked for, not that the app has none. +[[gnu::nonnull(1, 2), nodiscard]] const char* ffAndroidGetAppLabel(const char* packageName, FFstrbuf* result); diff --git a/src/common/android/dex.c b/src/common/android/dex.c index da18beb71..9d3dcf693 100644 --- a/src/common/android/dex.c +++ b/src/common/android/dex.c @@ -1,4 +1,5 @@ #include "common/android/dex.h" +#include "common/android/zip.h" #include "common/debug.h" #include "common/io.h" #include "common/mallocHelper.h" @@ -54,43 +55,6 @@ #define FF_DEX_OP_FIELD_FIRST 0x52 #define FF_DEX_OP_FIELD_LAST 0x5F -// 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_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 @@ -879,96 +843,20 @@ static const char* dexMapJar(const char* jarPath, FFDexMapping* mapping) { return nullptr; } -// 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. +// Locates `classesN.dex` in the jar's central directory. The walk that finds an entry is the same +// one the app label reader needs for an APK's resource table, so it lives in common/android/zip.h +// and this is the dex-specific half of the question: which name to ask for. 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"; + FFAndroidZipEntry found = {}; + const char* error = ffAndroidZipFindEntry(jar, jarSize, entry, &found); + if (error != nullptr) { + return error; } - - // 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; - } - 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; - } - } - if (eocd == SIZE_MAX) { - FF_DEBUG("No end-of-central-directory record in the last %zu bytes of a %zu byte jar", - newest - oldest + FF_ZIP_EOCD_SIZE, jarSize); - 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"; - } - 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) { - FF_DEBUG("A zip entry claims %zu bytes but only %zu remain in the directory", - entrySize, (size_t) (directoryEnd - offset)); - return "A zip directory entry runs past the directory"; - } - - 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) { - FF_DEBUG("The dex entry is zip64: compressed=%u, uncompressed=%u, localOffset=%u", - compressedSize, uncompressed, localOffset); - 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; - } - - offset += entrySize; - } - FF_DEBUG("No entry in the %zu byte jar is named %s", jarSize, entry); - return "The jar has no usable dex entry"; + *data = found.payload; + *dataSize = found.payloadSize; + *uncompressedSize = found.uncompressedSize; + *method = found.method; + return nullptr; } // Fallback for a jar whose entry is STORED but whose header has no sizes: the dex magic is then a diff --git a/src/common/android/zip.c b/src/common/android/zip.c new file mode 100644 index 000000000..5510cb763 --- /dev/null +++ b/src/common/android/zip.c @@ -0,0 +1,100 @@ +#include "common/android/zip.h" +#include "common/debug.h" + +#include + +static uint16_t zipU16(const uint8_t* p) { + return (uint16_t) ((uint16_t) p[0] | (uint16_t) ((uint16_t) p[1] << 8)); +} + +static uint32_t zipU32(const uint8_t* p) { + return (uint32_t) p[0] | ((uint32_t) p[1] << 8) | ((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24); +} + +const char* ffAndroidZipFindEntry(const uint8_t* archive, size_t archiveSize, const char* entry, FFAndroidZipEntry* result) { + const size_t entryLength = strlen(entry); + if (archiveSize < FF_ZIP_EOCD_SIZE) { + return "The archive 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 = archiveSize - FF_ZIP_EOCD_SIZE; + const size_t oldest = archiveSize > FF_ZIP_EOCD_MAX_COMMENT + FF_ZIP_EOCD_SIZE + ? archiveSize - FF_ZIP_EOCD_MAX_COMMENT - FF_ZIP_EOCD_SIZE + : 0; + size_t eocd = SIZE_MAX; + for (size_t i = newest;; --i) { + if (memcmp(archive + i, FF_ZIP_EOCD_MAGIC, sizeof(FF_ZIP_EOCD_MAGIC) - 1) == 0) { + eocd = i; + break; + } + 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; + } + } + if (eocd == SIZE_MAX) { + FF_DEBUG("No end-of-central-directory record in the last %zu bytes of a %zu byte archive", + newest - oldest + FF_ZIP_EOCD_SIZE, archiveSize); + return "The archive has no zip directory"; + } + + const uint32_t directorySize = zipU32(archive + eocd + FF_ZIP_OFF_EOCD_DIRECTORY_SIZE); + const uint32_t directoryOffset = zipU32(archive + eocd + FF_ZIP_OFF_EOCD_DIRECTORY); + if (directoryOffset > archiveSize || directorySize > archiveSize - directoryOffset) { + return "The zip directory is out of range"; + } + + // The directory is walked to its end rather than for the entry count the record carries: that + // count is a 16-bit field which a 65535-entry archive pins at 0xFFFF and moves into a zip64 + // record, while the two bounds above describe the directory itself on every archive this small. + const size_t directoryEnd = (size_t) directoryOffset + directorySize; + for (size_t offset = directoryOffset; offset + FF_ZIP_CENTRAL_SIZE <= directoryEnd;) { + if (memcmp(archive + offset, FF_ZIP_CENTRAL_MAGIC, sizeof(FF_ZIP_CENTRAL_MAGIC) - 1) != 0) { + return "A zip directory entry is malformed"; + } + const uint16_t nameLength = zipU16(archive + offset + FF_ZIP_OFF_CENTRAL_NAME_LENGTH); + const uint16_t extraLength = zipU16(archive + offset + FF_ZIP_OFF_CENTRAL_EXTRA_LENGTH); + const uint16_t commentLength = zipU16(archive + 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) { + FF_DEBUG("A zip entry claims %zu bytes but only %zu remain in the directory", + entrySize, (size_t) (directoryEnd - offset)); + return "A zip directory entry runs past the directory"; + } + + if (nameLength == entryLength && memcmp(archive + offset + FF_ZIP_CENTRAL_SIZE, entry, entryLength) == 0) { + const uint32_t compressedSize = zipU32(archive + offset + FF_ZIP_OFF_CENTRAL_COMPRESSED_SIZE); + const uint32_t uncompressedSize = zipU32(archive + offset + FF_ZIP_OFF_CENTRAL_UNCOMPRESSED_SIZE); + const uint32_t localOffset = zipU32(archive + offset + FF_ZIP_OFF_CENTRAL_LOCAL_OFFSET); + if (compressedSize == FF_ZIP_ZIP64_SENTINEL || uncompressedSize == FF_ZIP_ZIP64_SENTINEL || localOffset == FF_ZIP_ZIP64_SENTINEL) { + FF_DEBUG("The zip entry \"%s\" is zip64: compressed=%u, uncompressed=%u, localOffset=%u", + entry, compressedSize, uncompressedSize, localOffset); + return "The zip entry is stored as zip64, which is not supported"; + } + if (localOffset > archiveSize || FF_ZIP_LOCAL_HEADER_SIZE > archiveSize - localOffset) { + return "A zip local header is out of range"; + } + + const uint16_t localNameLength = zipU16(archive + localOffset + FF_ZIP_OFF_NAME_LENGTH); + const uint16_t localExtraLength = zipU16(archive + localOffset + FF_ZIP_OFF_EXTRA_LENGTH); + const size_t headerSize = FF_ZIP_LOCAL_HEADER_SIZE + (size_t) localNameLength + (size_t) localExtraLength; + if (headerSize > archiveSize - localOffset || compressedSize > archiveSize - localOffset - headerSize) { + return "The zip entry bounds are out of range"; + } + + result->method = zipU16(archive + offset + FF_ZIP_OFF_CENTRAL_METHOD); + result->uncompressedSize = uncompressedSize; + result->payload = archive + localOffset + headerSize; + result->payloadSize = compressedSize; + return nullptr; + } + + offset += entrySize; + } + FF_DEBUG("No entry in the %zu byte archive is named \"%s\"", archiveSize, entry); + return "The archive has no entry with that name"; +} diff --git a/src/common/android/zip.h b/src/common/android/zip.h new file mode 100644 index 000000000..398991e59 --- /dev/null +++ b/src/common/android/zip.h @@ -0,0 +1,66 @@ +#pragma once + +#include "fastfetch.h" + +// Locating an entry inside a zip, which is what both the jar reader and the app label reader need: +// one looks for `classesN.dex`, the other for the resource table of an APK. +// +// The entry is found through the central directory rather than by scanning for its local header, +// and that is not a micro-optimisation. A local header is preceded by everything that came before +// it, so scanning costs a pass over all of it, once per lookup: `framework.jar` is 52 MB and its +// `classes2.dex` starts 9.6 MB in. Measured on the test device, where the sound module resolves +// seven codes, looking the entries up by scanning was the difference between 30 ms and 2 seconds. +// +// 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. + +// The zip local file header, from APPNOTE.TXT 4.3.7. +#define FF_ZIP_LOCAL_HEADER_SIZE 30 +#define FF_ZIP_OFF_NAME_LENGTH 26 +#define FF_ZIP_OFF_EXTRA_LENGTH 28 + +// The central directory. +#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 +// and the APKs measured so far 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 + +// One entry, as the archive describes it. `payload` points into the mapping the caller passed in and +// is not a copy: for a STORED entry it is the file's own bytes, and for a DEFLATED one it is the raw +// deflate stream, which the caller inflates with `uncompressedSize` as the bound. +typedef struct FFAndroidZipEntry { + const uint8_t* payload; + size_t payloadSize; + uint32_t uncompressedSize; + uint16_t method; +} FFAndroidZipEntry; + +// Resolves `entry` through the archive's central directory, writing where its payload is into +// `result`. Returns nullptr on success, a static message otherwise -- an archive that is not a zip, +// a directory that is out of range, an entry that is not there and an entry stored as zip64 are all +// reported rather than answered with a pointer into nothing. +[[gnu::nonnull(1, 3, 4), nodiscard]] const char* ffAndroidZipFindEntry(const uint8_t* archive, size_t archiveSize, const char* entry, FFAndroidZipEntry* result); diff --git a/src/detection/media/media_android.c b/src/detection/media/media_android.c index 6efa3790e..e698bd205 100644 --- a/src/detection/media/media_android.c +++ b/src/detection/media/media_android.c @@ -1,7 +1,9 @@ #include "media.h" #include "common/android/api.h" +#include "common/android/applabel.h" #include "common/android/binder.h" #include "common/android/dex.h" +#include "common/android/package.h" #include "common/debug.h" #include "common/io.h" #include "common/mallocHelper.h" @@ -31,8 +33,9 @@ // service and reaches onTransact, and is then answered with a SecurityException carrying the server's // own stack (`enforceMediaPermissions` <- `verifySessionsRequest` <- `getSessions`), while the shell // UID gets the session list from the same transaction code. Nothing in this file tests the uid: the -// service is asked and its answer is believed, so a device whose policy is looser, or one where a -// notification listener is registered, works without a change here. +// service is asked and its answer is believed, so a device whose policy is looser works without a +// change here. What the answer does depend on is the ComponentName in the request, which is not a +// filter and not decoration either -- see the request build in androidDetectMediaSession(). // // Only one of the two routes is ever asked, and which one it is depends on the display server. The // binder route is Android's, and the session it reads is the one the phone is playing only while @@ -94,8 +97,21 @@ // strings; a reply that did not fit would be reported rather than misread. #define FF_MEDIA_ANDROID_REPLY_SIZE (16 * 1024) -// An interface token plus, for getSessions, two int32s. -#define FF_MEDIA_ANDROID_REQUEST_SIZE 128 +// ffAndroidGetOwnPackage() writes at most this many bytes, terminator included. +#define FF_MEDIA_ANDROID_PACKAGE_SIZE 128 + +// The request is the interface token, the ComponentName and the user id. A ComponentName is a +// presence marker in front of the package name and the class name, so the two string16s are what +// took this past the 128 it used to be. That size had room for a seven-character package name -- +// `com.termux` with an empty class is already 136 bytes -- so the size is derived from the longest +// name that can be written rather than rounded up by hand, because a request that does not fit is +// dropped and reported as truncated: that fails the whole detection instead of degrading to a call +// without a name. The empty class name costs a 4-byte length, a NUL terminator and the padding, so 8. +// clang-format off +#define FF_MEDIA_ANDROID_REQUEST_SIZE \ + (12 + ((4 + 2 * sizeof(FF_MEDIA_ANDROID_SESSIONS_DESCRIPTOR) + 3) & ~3u) \ + + 4 + ((4 + 2 * FF_MEDIA_ANDROID_PACKAGE_SIZE + 3) & ~3u) + 8u + 4u) +// clang-format on // Lossless WebP reads quality as effort rather than as fidelity: 0 is "compress quickly and accept a // larger file", 100 is "spend the time". Measured on a 363x363 cover, the whole range is worth 1.2% @@ -794,12 +810,18 @@ static void androidReadPackageName(FFBinder* binder, uint32_t token, uint32_t tr return; } - // `player` wants the name the app shows, and there is no way to get one from here: `cmd package - // dump` for this app is 1.2 MB and carries `labelRes`, a resource id, rather than a name, and - // resolving it would mean reading the app's resource table. The package name is what the - // notification and the settings screen fall back to as well, so both fields get it -- the same - // fallback media_linux.c uses when MPRIS answers without an Identity. + // `player` wants the name the app shows rather than the name it is installed under, and the + // package name is only the fallback. The label comes out of the APK's own resource table, which + // the package manager points at with `sourceDir` and `labelRes`: the table is *stored* rather + // than deflated -- the platform expects to mmap it -- so resolving an id out of it needs no + // decompressor. What keeps the package name are the apps whose label is a literal string in + // their manifest instead of a resource reference, since their reply carries no id at all; that + // is also what the notification and the settings screen fall back to. See doc/app-label.md. ffStrbufSet(&result->player, &result->playerId); + const char* labelError = ffAndroidGetAppLabel(result->playerId.chars, &result->player); + if (labelError != nullptr) { + FF_DEBUG("The label of \"%s\" could not be resolved: %s", result->playerId.chars, labelError); + } } static void androidReadMetadata(FFBinder* binder, uint32_t token, uint32_t transaction, FFMediaResult* result, bool saveCover) { @@ -859,7 +881,8 @@ static void androidReadSession(FFBinder* binder, uint32_t token, const FFMediaAn // is the service's decision to make and it is not the only way in -- a notification listener is let // through too, an OEM can loosen the check, a release can change it -- so the module asks and reads the // answer. That costs one transaction, and it keeps the module working wherever the check happens to -// pass, instead of hard-coding today's policy into the client. +// pass, instead of hard-coding today's policy into the client. What the request has to carry for the +// check to be consulted at all is the ComponentName: see the request build below. static const char* androidDetectMediaSession(FFMediaResult* media, bool saveCover) { // Resolved before the binder is opened, so that a jar which does not declare the methods says so // rather than the module failing later with a message about the transport. The reader's own @@ -911,7 +934,38 @@ static const char* androidDetectMediaSession(FFMediaResult* media, bool saveCove uint8_t request[FF_MEDIA_ANDROID_REQUEST_SIZE]; FFBinderParcel parcel = ffBinderParcelCreate(request, sizeof(request)); ffBinderParcelPutInterfaceToken(&parcel, FF_MEDIA_ANDROID_SESSIONS_DESCRIPTOR); - ffBinderParcelPutI32(&parcel, 0); // ComponentName: null asks for every session + + // The ComponentName is not a filter and not decoration: it decides which branch of the + // service's permission check runs, and with a null one the branch this module needs is never + // reached. MediaSessionService.enforceMediaPermissions() admits the shell and root, a holder of + // android.permission.MEDIA_CONTENT_CONTROL, and a UID that owns an enabled notification + // listener -- but the last one is reached through isEnabledNotificationListener(compName, uid), + // which answers false for a null compName by construction. A null therefore asks for every + // session only on behalf of a UID that was going to be admitted anyway. + // + // Measured on the test device, one binary run by three UIDs: the shell gets the session list + // for every shape below; the app UID that owns an enabled listener gets -1 EX_SECURITY for a + // null ComponentName and the session list for a non-null one; an app UID that owns none gets + // -1 EX_SECURITY for both. Two more facts + // come out of the same run: the class inside the ComponentName is never validated -- a name no + // package declares and an empty string are answered exactly like a real one -- and the package + // has to be one the calling uid owns, because the service's own enforcePackageName() rejects + // anything else with EX_ILLEGAL_ARGUMENT ("packageName does not belong to the calling uid") + // before the permission check is reached. + // + // So the caller declares its own package and the class is left empty rather than invented: + // this is a standalone binary with no component of its own to name, and naming one the device + // does not have would be a claim that cannot be backed. A build whose package cannot be + // derived sends the null request, which is what this did before. + char package[FF_MEDIA_ANDROID_PACKAGE_SIZE]; + if (ffAndroidGetOwnPackage(package, sizeof(package))) { + ffBinderParcelPutI32(&parcel, 1); + ffBinderParcelPutString16(&parcel, package); + ffBinderParcelPutString16(&parcel, ""); + } else { + FF_DEBUG("Cannot determine the package of this process, so the request declares no component"); + ffBinderParcelPutI32(&parcel, 0); + } ffBinderParcelPutI32(&parcel, 0); // userId: the single user a phone has FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); @@ -927,11 +981,13 @@ static const char* androidDetectMediaSession(FFMediaResult* media, bool saveCove if (exception != 0) { // -1 is EX_SECURITY, which is what a UID the service will not read sessions for gets. The // number goes to the debug log rather than into the message: what tells the two cases apart - // for the user is which UID is asking. + // for the user is which UID is asking. On an app UID the missing privilege is named, + // because it is one the user can grant -- the request below already declares the caller, + // so whatever is left is the listener the UID does not own. FF_DEBUG("getSessions came back as exception %d%s", exception, exception == -1 ? " (EX_SECURITY)" : ""); return ffAndroidIsRootOrShell(instance.state.platform.uid) ? "The media session service refused the request" - : "Reading the media session needs the shell UID or root: an app UID is refused by the media_session service"; + : "Reading the media session from an app UID needs an enabled notification listener, which this UID does not have"; } // The tokens arrive as flat binder objects, which ffBinderTransact() collects in payload order