From d223b04ad8cd4da16bd3ba47b37663144bde097e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=80=9A=E6=B4=B2?= Date: Fri, 25 Sep 2026 00:59:07 +0800 Subject: [PATCH] Media (Android): adds support --- CHANGELOG.md | 3 +- CMakeLists.txt | 17 +- src/detection/media/media_android.c | 956 ++++++++++++++++++++++++++++ src/detection/media/media_linux.c | 12 +- 4 files changed, 979 insertions(+), 9 deletions(-) create mode 100644 src/detection/media/media_android.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 4442e2979..fe2226976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ Features: * Battery level and charging state are now available to apps; detailed battery information requires root or ADB. (Battery) * Added display mode, physical size, rotation, and HDR information. App-based detection requires Android 13 (API 33); root or ADB can also detect displays on older versions. (Display) * Added Wi-Fi interface, connection, standard, and security information. Android 11 (API 30) or later is required. Termux:API and location permission are needed to reveal SSID and BSSID; otherwise, they appear as ``. (Wifi) - * Added Wallpaper detection on Android, which effectively reports `/data/system/users/0/wallpaper`. (Wallpaper) + * Added Wallpaper detection, which effectively reports `/data/system/users/0/wallpaper`. (Wallpaper) + * Added Media detection, root/ADB only. (Media / Player) * Improved macOS support: * Improved process-name detection in Top. (Top) * Improved wallpaper detection on macOS Sonoma and later. (#2559, Wallpaper) diff --git a/CMakeLists.txt b/CMakeLists.txt index d44f3f77b..a7a47a243 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -692,6 +692,7 @@ elseif(ANDROID) src/detection/locale/locale_linux.c src/detection/localip/localip_linux.c src/detection/gamepad/gamepad_nosupport.c + src/detection/media/media_android.c src/detection/media/media_linux.c src/detection/memory/memory_linux.c src/detection/mouse/mouse_nosupport.c @@ -2059,12 +2060,12 @@ elseif(ANDROID) endif() endif() - # Both APIs (AImageDecoder, AMediaCodec) are newer than the API level this build targets -- - # AImageDecoder is 30, and AMediaCodec_getName / AMediaCodec_releaseName are 28 -- while fastfetch - # keeps running on devices below those levels. The NDK headers mark the entry points unavailable, so - # they have to be weak references: otherwise the executable would fail to load on an older device. - # Guarding every use with __builtin_available() is what makes that safe, and turning the guard - # diagnostic into an error is what keeps a use from slipping past it. + # Three APIs (AImageDecoder, AndroidBitmap_compress, AMediaCodec) are newer than the API level this + # build targets -- the first two are 30, and AMediaCodec_getName / AMediaCodec_releaseName are 28 -- + # while fastfetch keeps running on devices below those levels. The NDK headers mark the entry points + # unavailable, so they have to be weak references: otherwise the executable would fail to load on an + # older device. Guarding every use with __builtin_available() is what makes that safe, and turning + # the guard diagnostic into an error is what keeps a use from slipping past it. # # camera_android.c is deliberately not in this list. The camera2 NDK is a *library* that starts at # API 24, and a weak reference weakens the symbol, never the library: DT_NEEDED would still name @@ -2072,9 +2073,11 @@ elseif(ANDROID) # is explicit that there is no such thing as a weak library reference, and that dlopen is the only # way to reach a library newer than minSdkVersion. Nothing here pins ANDROID_PLATFORM, so a build # aimed below 24 would need that; on the supported path -- Termux, which targets 24 -- the library - # is always present and the plain call is correct. + # is always present and the plain call is correct. libjnigraphics itself is one of them and is + # linked unconditionally above, so the encoder only needs its symbol weakened, not its library. set_source_files_properties( ${CMAKE_CURRENT_SOURCE_DIR}/src/logo/image/aid.c + ${CMAKE_CURRENT_SOURCE_DIR}/src/detection/media/media_android.c ${CMAKE_CURRENT_SOURCE_DIR}/src/detection/codec/codec_android.c PROPERTIES COMPILE_OPTIONS "-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__;-Werror=unguarded-availability") elseif(Haiku) diff --git a/src/detection/media/media_android.c b/src/detection/media/media_android.c new file mode 100644 index 000000000..4537ac890 --- /dev/null +++ b/src/detection/media/media_android.c @@ -0,0 +1,956 @@ +#include "media.h" +#include "common/android/api.h" +#include "common/android/binder.h" +#include "common/debug.h" +#include "common/io.h" +#include "common/mallocHelper.h" +#include "detection/displayserver/displayserver.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Android's "now playing" belongs to the media_session system service. There is no C API for it -- +// the NDK exposes neither the service nor a Java bridge -- and `/system/bin/dumpsys media_session` +// costs a fork/exec (measured at ~70 ms on this device) while answering with less. This file speaks +// to the service over /dev/binder through common/android/binder.h instead, the same way the wallpaper +// and wifi modules do, and pays ~0.1 ms per call. +// +// The gate is a single method. `ISessionManager.getSessions(ComponentName, int)` is the only entry +// point that checks a permission: MediaSessionService.enforceMediaPermissions() admits the system UI, +// a holder of android.permission.MEDIA_CONTENT_CONTROL and an enabled notification listener, and +// refuses everything else -- measured on an Android 16 device, an app UID opens binder, finds the +// 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. +// +// 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 +// SurfaceFlinger is the display server; a Termux:X11 or Wayland session runs a real compositor on top +// of the device, and the players running there are not Android media sessions at all -- so that case +// is handed to the MPRIS implementation in media_linux.c, which is compiled alongside this file for +// exactly this reason. +// +// Under SurfaceFlinger the Android session is still not the whole story. When it has nothing to say -- +// an app UID, or a phone with nothing registered -- the module falls back to that same MPRIS +// implementation, over the session bus. That bus is open to every process in the session, so the +// fallback is the only route an app UID has and a real one for the shell UID too. +// +// What the service answers with is worth the trouble, and more than the dump carries. The reply to +// getSessions is a list of session tokens, and each token *is* an ISessionController binder -- there +// is no getController() hop -- and every argument-less getter on that interface was measured to +// answer without a permission check, so the one gate above is the whole story. getMetadata yields the +// title, the artist, the album and, unlike the dump, the track length; getPlaybackState yields the +// state, the position and the playback speed; and the artwork comes across as a Bitmap whose pixels +// arrive as an ashmem descriptor, which is what makes a real cover possible here. +// +// Two traps, both measured: +// +// * A request must ask with TF_ACCEPT_FDS, or a reply that carries a descriptor is refused with +// BR_FAILED_REPLY -- which reads exactly like a service that is not running. The failure is +// asymmetric and that is what makes it expensive: only metadata carrying artwork has a +// descriptor, so a probe tried against one player passes while the same probe fails against the +// next one. +// * A Bundle value is written by Parcel.writeValue, and for a Parcelable that means an int32 byte +// length in front of the class name (Parcel.isLengthPrefixed). Reading the class name without +// accounting for it, or reading a Bitmap as if it were a string, desynchronises every field after +// it. The length is also the one thing that makes an unknown Parcelable skippable. +// +#define FF_MEDIA_ANDROID_SERVICE "media_session" + +#define FF_MEDIA_ANDROID_SESSIONS_DESCRIPTOR "android.media.session.ISessionManager" +#define FF_MEDIA_ANDROID_CONTROLLER_DESCRIPTOR "android.media.session.ISessionController" + +// Transaction codes as this device's own framework.jar declares them, read out of its dex rather than +// transcribed from AOSP -- a vendor ROM adds and drops methods as it likes, and a wrong code reaches +// a different method or none at all. getSessions is the second declared method of ISessionManager; +// the numbers on ISessionController are sparse because most of that interface is setters this module +// never calls. +#define FF_MEDIA_ANDROID_GET_SESSIONS 2u +#define FF_MEDIA_ANDROID_GET_PACKAGE_NAME 5u +#define FF_MEDIA_ANDROID_GET_METADATA 32u +#define FF_MEDIA_ANDROID_GET_PLAYBACK_STATE 33u + +// The largest reply any of these getters produced is a metadata Bundle at ~1.6 KB, and the artwork +// never lands here -- it arrives as a descriptor. 16 KB leaves room for a Bundle of unusually long +// 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 + +// 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% +// of the file for 2.4 times the time, so there is nothing at the top to buy. +#define FF_MEDIA_ANDROID_COVER_QUALITY 0 + +// One reply buffer for every call in this file: the calls are sequential, and the metadata of the +// winning session is the last one to need it. +static uint8_t s_reply[FF_MEDIA_ANDROID_REPLY_SIZE]; + +// --------------------------------------------------------------------------------------------- +// Reading a Parcel +// --------------------------------------------------------------------------------------------- + +// android.os.Parcel value types, from Parcel.VAL_*. A MediaMetadata Bundle only ever holds the four +// below; anything else stops the walk there rather than being guessed at, and the caller is told. +typedef enum FFMediaAndroidValueType : int32_t { + FF_MEDIA_ANDROID_VAL_NULL = -1, + FF_MEDIA_ANDROID_VAL_STRING = 0, + FF_MEDIA_ANDROID_VAL_INTEGER = 1, + FF_MEDIA_ANDROID_VAL_PARCELABLE = 4, + FF_MEDIA_ANDROID_VAL_LONG = 6, + FF_MEDIA_ANDROID_VAL_CHAR_SEQUENCE = 10, +} FFMediaAndroidValueType; + +// Bundle.writeToParcel writes this in front of the entry count. It is the byte pattern 'BNDL', and +// checking it is what tells a bundle apart from the length word of something else. +#define FF_MEDIA_ANDROID_BUNDLE_MAGIC 0x4C444E42u + +#define FF_MEDIA_ANDROID_CLASS_BITMAP "android.graphics.Bitmap" + +#define FF_MEDIA_ANDROID_KEY_PREFIX "android.media.metadata." +#define FF_MEDIA_ANDROID_KEY_TITLE FF_MEDIA_ANDROID_KEY_PREFIX "TITLE" +#define FF_MEDIA_ANDROID_KEY_DISPLAY_TITLE FF_MEDIA_ANDROID_KEY_PREFIX "DISPLAY_TITLE" +#define FF_MEDIA_ANDROID_KEY_ARTIST FF_MEDIA_ANDROID_KEY_PREFIX "ARTIST" +#define FF_MEDIA_ANDROID_KEY_DISPLAY_SUBTITLE FF_MEDIA_ANDROID_KEY_PREFIX "DISPLAY_SUBTITLE" +#define FF_MEDIA_ANDROID_KEY_ALBUM FF_MEDIA_ANDROID_KEY_PREFIX "ALBUM" +#define FF_MEDIA_ANDROID_KEY_DURATION FF_MEDIA_ANDROID_KEY_PREFIX "DURATION" +#define FF_MEDIA_ANDROID_KEY_MEDIA_URI FF_MEDIA_ANDROID_KEY_PREFIX "MEDIA_URI" +#define FF_MEDIA_ANDROID_KEY_ART FF_MEDIA_ANDROID_KEY_PREFIX "ART" +#define FF_MEDIA_ANDROID_KEY_ALBUM_ART FF_MEDIA_ANDROID_KEY_PREFIX "ALBUM_ART" +#define FF_MEDIA_ANDROID_KEY_DISPLAY_ICON FF_MEDIA_ANDROID_KEY_PREFIX "DISPLAY_ICON" + +// MediaMetadata carries the artwork under three keys, and Android documents them as: ART is "the +// artwork for the media", ALBUM_ART "the artwork for the album it is from", DISPLAY_ICON "a small +// icon representing the media". They are usually the same image; ART is the one the system UI puts on +// the lock screen, so it wins when more than one is present. +typedef enum FFMediaAndroidCoverPriority : uint32_t { + FF_MEDIA_ANDROID_COVER_DISPLAY_ICON = 1, + FF_MEDIA_ANDROID_COVER_ALBUM_ART = 2, + FF_MEDIA_ANDROID_COVER_ART = 3, +} FFMediaAndroidCoverPriority; + +typedef struct FFMediaAndroidReader { + const uint8_t* data; + size_t size; + size_t position; +} FFMediaAndroidReader; + +[[gnu::nonnull(1, 2)]] static bool androidReadI32(FFMediaAndroidReader* reader, int32_t* value) { + if (reader->position + sizeof(int32_t) > reader->size) { + return false; + } + memcpy(value, reader->data + reader->position, sizeof(int32_t)); + reader->position += sizeof(int32_t); + return true; +} + +[[gnu::nonnull(1, 2)]] static bool androidReadI64(FFMediaAndroidReader* reader, int64_t* value) { + if (reader->position + sizeof(int64_t) > reader->size) { + return false; + } + memcpy(value, reader->data + reader->position, sizeof(int64_t)); + reader->position += sizeof(int64_t); + return true; +} + +// AIDL string16: an int32 code-unit count, that many UTF-16LE code units, a UTF-16 NUL terminator, +// and padding to four bytes. The terminator is walked over even though it is not part of the string, +// and so is the padding -- getting either wrong shifts every following field. A count of -1 is how +// the same field spells null. +[[gnu::nonnull(1, 2)]] static bool androidReadString16(FFMediaAndroidReader* reader, FFstrbuf* result) { + int32_t length = 0; + if (!androidReadI32(reader, &length)) { + return false; + } + if (length < 0) { + ffStrbufClear(result); + return true; + } + + const size_t units = (size_t) length * 2; + const size_t padded = (units + 2 + 3) & ~(size_t) 3; // the code units, the two byte NUL, then padding + if (reader->position + padded > reader->size) { + return false; + } + + const uint8_t* codeUnits = reader->data + reader->position; + reader->position += padded; + + ffStrbufClear(result); + for (size_t i = 0; i < (size_t) length; i++) { + uint32_t codepoint = (uint32_t) codeUnits[i * 2] | ((uint32_t) codeUnits[i * 2 + 1] << 8); + if (codepoint >= 0xD800 && codepoint <= 0xDBFF && i + 1 < (size_t) length) { + const uint32_t low = (uint32_t) codeUnits[(i + 1) * 2] | ((uint32_t) codeUnits[(i + 1) * 2 + 1] << 8); + if (low >= 0xDC00 && low <= 0xDFFF) { + codepoint = 0x10000u + ((codepoint - 0xD800u) << 10) + (low - 0xDC00u); + ++i; + } + } + ffStrbufAppendUtf32CodePoint(result, codepoint); + } + return true; +} + +// --------------------------------------------------------------------------------------------- +// The artwork +// --------------------------------------------------------------------------------------------- + +typedef struct FFMediaAndroidCover { + int fd; // a descriptor the reply handed over; whoever read the reply closes it + uint32_t width; + uint32_t height; + uint32_t priority; + bool found; +} FFMediaAndroidCover; + +// The pixels of a Bitmap cross the transaction as an ashmem descriptor, and the descriptor's length is +// the one thing that ties the blob back to its Bitmap: Parcel::writeBlob writes the byte count in +// front of the descriptor, so that count and the descriptor's own size must agree -- and the same +// identity (`width * height * 4`) is what makes the two dimensions recoverable without knowing the +// rest of Bitmap's parcel layout, which is written by native code and has changed between releases. +// +// Nothing is assumed about where in the body the dimensions sit: the walk is over the whole body and +// the arithmetic is the filter, so a wrong candidate is rejected rather than believed. A Bitmap whose +// pixels were small enough to be written inline has no descriptor at all and answers nothing here. +[[gnu::nonnull(1, 3, 4)]] static bool androidTakeBitmap(const uint8_t* body, size_t length, const FFBinderReply* reply, FFMediaAndroidCover* cover) { + for (uint32_t i = 0; i < reply->fdCount; i++) { + const off_t size = lseek(reply->fds[i], 0, SEEK_END); + if (size <= 0 || size > (off_t) UINT32_MAX || (size % 4) != 0) { + continue; + } + + size_t blobOffset = SIZE_MAX; + for (size_t offset = 0; offset + sizeof(int32_t) <= length; offset += sizeof(int32_t)) { + int32_t word = 0; + memcpy(&word, body + offset, sizeof(word)); + if (word > 0 && (uint32_t) word == (uint32_t) size) { + blobOffset = offset; + break; + } + } + if (blobOffset == SIZE_MAX) { + continue; + } + + for (size_t offset = 0; offset + 2 * sizeof(int32_t) <= blobOffset; offset += sizeof(int32_t)) { + int32_t width = 0; + int32_t height = 0; + memcpy(&width, body + offset, sizeof(width)); + memcpy(&height, body + offset + sizeof(width), sizeof(height)); + if (width < 1 || height < 1 || width > 8192 || height > 8192) { + continue; + } + if ((uint64_t) width * (uint64_t) height * 4 != (uint64_t) size) { + continue; + } + + cover->fd = reply->fds[i]; + cover->width = (uint32_t) width; + cover->height = (uint32_t) height; + cover->found = true; + FF_DEBUG("The cover is %dx%d, %lld bytes of pixels", width, height, (long long) size); + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------------------------- +// Writing the cover out +// --------------------------------------------------------------------------------------------- + +[[gnu::nonnull(2)]] +static bool androidWriteAll(FFNativeFD fd, const void* data, size_t length) { + const uint8_t* bytes = data; + while (length > 0) { + const ssize_t written = write(fd, bytes, length); + if (written < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + bytes += (size_t) written; + length -= (size_t) written; + } + return true; +} + +// The callback the encoder writes through. Its contract is that returning false stops it, which is +// what makes a full disk come out as no file rather than a truncated one. +typedef struct FFMediaAndroidCoverSink { + FFNativeFD fd; + size_t written; + bool failed; +} FFMediaAndroidCoverSink; + +[[gnu::nonnull(1)]] +static bool androidCoverSinkWrite(void* userContext, const void* data, size_t size) { + FFMediaAndroidCoverSink* sink = userContext; + if (!androidWriteAll(sink->fd, data, size)) { + sink->failed = true; + return false; + } + sink->written += size; + return true; +} + +// A Bitmap is premultiplied, and artwork without transparency is the same bytes either way -- so +// saying which it is costs one pass over the mapping and saves the encoder a bigger one. Declared +// premultiplied, AndroidBitmap_compress makes a pass over all 527 KB to unpremultiply pixels it then +// finds no alpha in: 41.1 ms against 37.6, for a file that came out byte for byte identical +// (sha256 943a6215... on all three of premultiplied, opaque and unpremultiplied). A cover that does +// carry transparency keeps the premultiplied description, and its alpha survives. +[[gnu::nonnull(1)]] static bool androidCoverIsOpaque(const uint8_t* pixels, size_t length) { + for (size_t i = 3; i < length; i += 4) { + if (pixels[i] != 0xFF) { + return false; + } + } + return true; +} + +// API 30. The pixels are handed over as the mapping itself: the encoder reads them strided, so the +// cover never takes a second copy on the way in. +FF_ANDROID_REQUIRES_API(30) +static bool androidCompressCover(FFNativeFD fd, const void* pixels, uint32_t width, uint32_t height, size_t* outLength) { + // Everything the description needs was already proved by the descriptor's own size, bar one + // thing: the pixels are RGBA8888 and tightly packed, which is what a Bitmap carries, while + // whether there is an alpha worth keeping is not in the size and has to be looked for. + const AndroidBitmapInfo info = { + .width = width, + .height = height, + .stride = width * 4, + .format = ANDROID_BITMAP_FORMAT_RGBA_8888, + .flags = androidCoverIsOpaque(pixels, (size_t) width * height * 4) + ? ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE + : ANDROID_BITMAP_FLAGS_ALPHA_PREMUL, + }; + + FFMediaAndroidCoverSink sink = { .fd = fd }; + const int result = AndroidBitmap_compress(&info, ADATASPACE_SRGB, pixels, ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS, FF_MEDIA_ANDROID_COVER_QUALITY, &sink, androidCoverSinkWrite); + if (result != ANDROID_BITMAP_RESULT_SUCCESS) { + FF_DEBUG("AndroidBitmap_compress returned %d", result); + return false; + } + + *outLength = sink.written; + return !sink.failed; +} + +// The descriptor holds the Bitmap's pixels and nothing else: no header, no pixel format and no +// length beyond the region's own size. Encoding them is the one thing the session service cannot do +// for this module, and the platform has the encoder -- see androidCompressCover above. +// +// What that encoder costs and produces, measured on this device with a real 363x363 cover (527076 +// bytes of premultiplied RGBA) and the clock around the call alone: +// +// format quality output time +// --------------- -------- ---------- ------- +// PNG any 203675 B ~370 ms quality is ignored, and all four settings +// produced byte-identical files +// WebP lossless 0 146884 B 41 ms +// WebP lossless 100 145064 B 98 ms the whole effort knob is worth 1.2% +// WebP lossy 80 22060 B 125 ms +// JPEG 90 39973 B 11 ms +// +// PNG is what the artwork usually arrives as and the worst choice here: the header documents that +// quality is ignored for it, and Skia pays for an adaptive filter pass a single image cannot +// amortise. Compressing by hand instead -- a deflate stream of stored blocks, which needs no library +// at all -- costs 2 ms but writes 527490 bytes, so the platform encoder trades 39 ms for a file 3.6 +// times smaller. +// +// Declaring the pixels opaque rather than premultiplied, which the scan in androidCompressCover +// makes a truthful thing to say, is worth another 3.5 ms of the 41; the two files are identical, so +// what is being bought is only the encoder's pass in search of an alpha that is not there. +// +// Lossless WebP is the format, for three measured reasons: it is nine times faster than PNG, it is +// 3.6 times smaller than what the hand-written encoder produced, and being lossless it keeps alpha -- +// which matters, because a Bitmap is premultiplied and an encoder that dropped the alpha without +// compositing would leave the premultiplied colour behind and turn what was transparent black. Every +// reader here takes it as well: the iTerm and kitty logo paths hand the file over as it is for the +// terminal to decode, and AImageDecoder, which reads it back inside fastfetch, handles it too. +// +// TMPDIR is what Termux exports, and it is the one directory the terminal that will draw the logo +// can also read; /data/local/tmp is the answer when fastfetch was started by something that exports +// nothing. That second one belongs to the shell UID and root, and Termux runs this module as an app +// UID for which it is not writable, so the candidates are tested rather than trusted. +[[gnu::nonnull(1)]] static const char* androidCoverPath(char* buffer, size_t size) { + const char* directories[] = { getenv("TMPDIR"), "/data/local/tmp" }; + for (size_t i = 0; i < sizeof(directories) / sizeof(directories[0]); i++) { + const char* directory = directories[i]; + if (directory == nullptr || directory[0] != '/') { + continue; + } + if (access(directory, W_OK) != 0) { + continue; + } + const int length = snprintf(buffer, size, "%s/fastfetch-media-cover-%d.webp", directory, (int) getpid()); + if (length <= 0 || (size_t) length >= size) { + continue; + } + return buffer; + } + return nullptr; +} + +// The file belongs to this process, and this is the only place that knows its name: media.c removes +// it once the logo is drawn, which is what removeCoverAfterUse asks for, and a half-written file +// would otherwise never be removed by anyone. +[[gnu::nonnull(1)]] static void androidWriteCover(FFMediaResult* result, int sourceFD, uint32_t width, uint32_t height) { + const off_t size = lseek(sourceFD, 0, SEEK_END); + if (size <= 0 || (uint64_t) size != (uint64_t) width * (uint64_t) height * 4) { + return; + } + + // AndroidBitmap_compress is API 30, and the build reaches it through a weak reference, so this + // is a run-time branch rather than a compile-time constant. There is no second encoder behind + // it: AImageDecoder, the only thing on Android that can read a cover back, is API 30 as well, so + // a file written below that would be one whose reader does not exist. + if (FF_ANDROID_API_AT_LEAST(30)) { + void* map = mmap(nullptr, (size_t) size, PROT_READ, MAP_SHARED, sourceFD, 0); + if (map == MAP_FAILED) { + return; + } + + char path[256]; + const char* file = androidCoverPath(path, sizeof(path)); + FF_AUTO_CLOSE_FD FFNativeFD fd = file != nullptr ? open(file, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600) : -1; + + size_t length = 0; + const bool written = ffIsValidNativeFD(fd) && androidCompressCover(fd, map, width, height, &length); + + munmap(map, (size_t) size); + + if (written) { + ffStrbufSetS(&result->cover, file); + result->removeCoverAfterUse = true; + FF_DEBUG("The %ux%u cover was written to %s, %zu bytes", width, height, file, (size_t) length); + } else if (file != nullptr) { + ffRemoveFile(file); + } + } +} + +// --------------------------------------------------------------------------------------------- +// The session +// --------------------------------------------------------------------------------------------- + +// Every getter on ISessionController takes no argument, so a call is the interface token and nothing +// else; `flags` is passed through because one of them needs TF_ACCEPT_FDS. +[[gnu::nonnull(1, 5)]] static const char* androidCall(FFBinder* binder, uint32_t handle, uint32_t code, uint32_t flags, FFBinderReply* reply) { + uint8_t request[FF_MEDIA_ANDROID_REQUEST_SIZE]; + FFBinderParcel parcel = ffBinderParcelCreate(request, sizeof(request)); + ffBinderParcelPutInterfaceToken(&parcel, FF_MEDIA_ANDROID_CONTROLLER_DESCRIPTOR); + + const char* error = ffBinderTransact(binder, handle, code, flags, &parcel, reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(reply)) { + // onTransact() answered with a raw status_t instead of a parcel. -74 is UNKNOWN_TRANSACTION, + // which is what a code this build of the interface does not have comes back as. + return ffBinderReadI32(reply->data, reply->size, 0) == -74 + ? "The session does not have that method" + : "The session rejected the request"; + } + + // Every reply starts with the exception code AIDL writes, so nothing past it is read before this + // has been checked. + const int32_t exception = ffBinderReadI32(reply->data, reply->size, 0); + if (exception != 0) { + FF_DEBUG("Transaction %u came back as exception %d over %zu bytes", code, exception, reply->size); + return "The session raised an exception"; + } + + return nullptr; +} + +[[gnu::nonnull(1, 4, 5, 6, 7)]] +static const char* androidReadMetadataEntry(FFMediaAndroidReader* reader, size_t bundleEnd, int32_t type, const FFstrbuf* key, FFMediaResult* result, const FFBinderReply* reply, FFMediaAndroidCover* cover) { + switch (type) { + case FF_MEDIA_ANDROID_VAL_NULL: + return nullptr; + + case FF_MEDIA_ANDROID_VAL_STRING: + case FF_MEDIA_ANDROID_VAL_CHAR_SEQUENCE: { + FF_STRBUF_AUTO_DESTROY value = ffStrbufCreate(); + if (!androidReadString16(reader, &value)) { + return "The metadata carries a truncated string"; + } + if (value.length == 0) { + return nullptr; + } + + // DISPLAY_TITLE and DISPLAY_SUBTITLE are the fallbacks Android documents for a session + // whose structured metadata is incomplete -- a radio stream, typically -- and an empty + // TITLE is exactly the case they exist for. + if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_TITLE)) { + ffStrbufSet(&result->song, &value); + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_DISPLAY_TITLE)) { + if (result->song.length == 0) { + ffStrbufSet(&result->song, &value); + } + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_ARTIST)) { + ffStrbufSet(&result->artist, &value); + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_DISPLAY_SUBTITLE)) { + if (result->artist.length == 0) { + ffStrbufSet(&result->artist, &value); + } + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_ALBUM)) { + ffStrbufSet(&result->album, &value); + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_MEDIA_URI)) { + ffStrbufSet(&result->url, &value); + } + return nullptr; + } + + case FF_MEDIA_ANDROID_VAL_LONG: { + int64_t value = 0; + if (!androidReadI64(reader, &value)) { + return "The metadata carries a truncated long"; + } + // METADATA_KEY_DURATION is milliseconds, where MPRIS answers microseconds -- the one + // field that has to be read differently on each platform. + if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_DURATION) && value > 0 && value <= (int64_t) UINT32_MAX) { + result->length = (uint32_t) value; + } + return nullptr; + } + + case FF_MEDIA_ANDROID_VAL_INTEGER: { + int32_t value = 0; + if (!androidReadI32(reader, &value)) { + return "The metadata carries a truncated int"; + } + return nullptr; + } + + case FF_MEDIA_ANDROID_VAL_PARCELABLE: { + // Parcel.writeValue length-prefixes a Parcelable with the byte size of everything that + // follows, and that size is what makes an unknown class skippable: the walk resumes at the + // end of the payload instead of having to understand it. + int32_t payloadLength = 0; + if (!androidReadI32(reader, &payloadLength) || payloadLength < 0) { + return "The metadata carries a malformed parcelable"; + } + const size_t payloadEnd = reader->position + (size_t) payloadLength; + if (payloadEnd > bundleEnd) { + return "The metadata carries a parcelable past the end of the bundle"; + } + + FF_STRBUF_AUTO_DESTROY className = ffStrbufCreate(); + if (!androidReadString16(reader, &className)) { + return "The metadata carries a truncated parcelable name"; + } + + if (ffStrbufEqualS(&className, FF_MEDIA_ANDROID_CLASS_BITMAP)) { + uint32_t priority = 0; + if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_ART)) { + priority = FF_MEDIA_ANDROID_COVER_ART; + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_ALBUM_ART)) { + priority = FF_MEDIA_ANDROID_COVER_ALBUM_ART; + } else if (ffStrbufEqualS(key, FF_MEDIA_ANDROID_KEY_DISPLAY_ICON)) { + priority = FF_MEDIA_ANDROID_COVER_DISPLAY_ICON; + } + + if (priority > cover->priority) { + FFMediaAndroidCover candidate = { .fd = -1 }; + if (androidTakeBitmap(reader->data + reader->position, payloadEnd - reader->position, reply, &candidate)) { + candidate.priority = priority; + *cover = candidate; + } + } + } + + reader->position = payloadEnd; + return nullptr; + } + + default: + // Anything else -- a map, a list, an array -- would have to be walked by shape to be + // skipped, and a MediaMetadata Bundle does not carry one. Stopping here is what keeps the + // walk from reading a wrong number of bytes and carrying on with nonsense. + FF_DEBUG("The metadata carries an unsupported value type %d", type); + return "The metadata carries a value this build cannot read"; + } +} + +[[gnu::nonnull(1, 2, 3, 4)]] +static const char* androidReadMetadataBundle(FFMediaAndroidReader* reader, FFMediaResult* result, const FFBinderReply* reply, FFMediaAndroidCover* cover) { + int32_t bundleLength = 0; + if (!androidReadI32(reader, &bundleLength) || bundleLength <= 0) { + return "The metadata bundle is empty"; + } + const size_t bundleEnd = reader->position + (size_t) bundleLength; + if (bundleEnd > reader->size) { + return "The metadata bundle runs past the end of the reply"; + } + + int32_t magic = 0; + if (!androidReadI32(reader, &magic) || (uint32_t) magic != FF_MEDIA_ANDROID_BUNDLE_MAGIC) { + return "The metadata is not a bundle"; + } + int32_t count = 0; + if (!androidReadI32(reader, &count)) { + return "The metadata bundle has no entry count"; + } + + FF_STRBUF_AUTO_DESTROY key = ffStrbufCreate(); + int32_t read = 0; + for (; read < count && reader->position < bundleEnd; read++) { + if (!androidReadString16(reader, &key)) { + return "The metadata carries a truncated key"; + } + int32_t type = 0; + if (!androidReadI32(reader, &type)) { + return "The metadata carries a value without a type"; + } + const char* error = androidReadMetadataEntry(reader, bundleEnd, type, &key, result, reply, cover); + if (error != nullptr) { + return error; + } + } + + // The count in the header is the check on the walk: a discrepancy means a type this build reads the + // wrong width for, and a partially read title is worse than none. + if (read != count) { + FF_DEBUG("The metadata bundle declared %d entries but only %d could be read", count, read); + return "The metadata bundle did not read back in full"; + } + + return nullptr; +} + +// PlaybackState.STATE_*, which is what getPlaybackState answers with. The three names MPRIS uses are +// kept identical -- Playing, Paused, Stopped -- so that a format string written for the Linux or the +// Windows implementation keeps working. +static const char* androidPlaybackStateName(int32_t state) { + switch (state) { + case 0: return "None"; + case 1: return "Stopped"; + case 2: return "Paused"; + case 3: return "Playing"; + case 4: return "FastForwarding"; + case 5: return "Rewinding"; + case 6: return "Buffering"; + case 7: return "Error"; + case 8: return "Connecting"; + case 9: return "SkippingToPrevious"; + case 10: return "SkippingToNext"; + case 11: return "SkippingToQueueItem"; + default: return "Unknown"; + } +} + +static uint32_t androidPlaybackStateScore(int32_t state) { + // Playing first, then the states that are on their way to it, then paused. A session that is + // stopped or has no state at all is only reported when nothing else is there. + switch (state) { + case 3: return 4; // PLAYING + case 4: case 5: case 6: case 8: case 9: case 10: case 11: return 3; // moving, buffering, connecting + case 2: return 2; // PAUSED + case 1: case 7: return 1; // STOPPED, ERROR + default: return 0; + } +} + +// SystemClock.elapsedRealtime(), which is the clock PlaybackState.updateTime is stamped with. It is +// CLOCK_BOOTTIME and not CLOCK_MONOTONIC: a phone suspends with music playing often enough that the +// two differ by hours, and the difference would be read as a position that never advances. +static int64_t androidElapsedRealtime(void) { + struct timespec now = {}; + if (clock_gettime(CLOCK_BOOTTIME, &now) != 0) { + return 0; + } + return (int64_t) now.tv_sec * 1000 + (int64_t) (now.tv_nsec / 1000000); +} + +// The reply is walked only as far as the speed: everything past it is a typed list of custom actions, +// an item id, a CharSequence and a Bundle, none of which this module reports. `length` therefore has +// to have been read already -- the clamping below needs it -- which is why the metadata pass runs +// first. +[[gnu::nonnull(1, 2)]] static const char* androidReadPlaybackState(FFMediaAndroidReader* reader, FFMediaResult* result) { + int32_t state = 0; + if (!androidReadI32(reader, &state) || state == 0) { + return nullptr; // the session has no state yet + } + + int64_t position = 0; + if (!androidReadI64(reader, &position)) { + return "The playback state is truncated"; + } + + float speed = 0; + if (reader->position + sizeof(float) > reader->size) { + return "The playback state is truncated"; + } + memcpy(&speed, reader->data + reader->position, sizeof(speed)); + reader->position += sizeof(speed); + + int64_t updateTime = 0; + if (!androidReadI64(reader, &updateTime)) { + return "The playback state is truncated"; + } + + // `position` is the position *at* updateTime, so a snapshot taken now has to be advanced by the + // time that has passed since -- otherwise a player that only refreshes its state once per track + // would report the same position for the whole of it. Only a playing session advances: a paused + // one is exactly where it says it is. + int64_t current = position; + if (state == 3 && speed > 0) { + const int64_t now = androidElapsedRealtime(); + if (now > updateTime) { + current += (int64_t) ((double) (now - updateTime) * (double) speed); + } + } + + if (current < 0) { + current = 0; + } + if (result->length > 0 && current > (int64_t) result->length) { + current = result->length; + } + if (current > 0 && current <= (int64_t) UINT32_MAX) { + result->position = (uint32_t) current; + } + + ffStrbufSetS(&result->status, androidPlaybackStateName(state)); + return nullptr; +} + +static void androidReadPackageName(FFBinder* binder, uint32_t token, FFMediaResult* result) { + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + const char* error = androidCall(binder, token, FF_MEDIA_ANDROID_GET_PACKAGE_NAME, 0, &reply); + if (error != nullptr) { + FF_DEBUG("getPackageName failed: %s", error); + return; + } + + FFMediaAndroidReader reader = { .data = reply.data, .size = reply.size, .position = 4 }; + if (!androidReadString16(&reader, &result->playerId)) { + 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. + ffStrbufSet(&result->player, &result->playerId); +} + +static void androidReadMetadata(FFBinder* binder, uint32_t token, FFMediaResult* result, bool saveCover) { + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + + // TF_ACCEPT_FDS is not a hint and not optional here: the artwork arrives as a descriptor, and a + // request that did not ask for one is answered with BR_FAILED_REPLY -- which reads like a service + // that is not running rather than like a missing flag. It is passed unconditionally, because the + // alternative is a failure that depends on whether the player happened to publish artwork. + const char* error = androidCall(binder, token, FF_MEDIA_ANDROID_GET_METADATA, TF_ACCEPT_FDS, &reply); + + FFMediaAndroidCover cover = { .fd = -1 }; + if (error != nullptr) { + FF_DEBUG("getMetadata failed: %s", error); + } else if (ffBinderReadI32(reply.data, reply.size, 4) != 0) { + FFMediaAndroidReader reader = { .data = reply.data, .size = reply.size, .position = 8 }; + const char* parseError = androidReadMetadataBundle(&reader, result, &reply, &cover); + if (parseError != nullptr) { + FF_DEBUG("The metadata bundle could not be read: %s", parseError); + } + } + + if (cover.found && saveCover) { + androidWriteCover(result, cover.fd, cover.width, cover.height); + } + + // Every descriptor the reply carried is this side's to close, the cover's included: the pixels + // have been read out by now, and the one no cover came from would otherwise leak. + for (uint32_t i = 0; i < reply.fdCount; i++) { + close(reply.fds[i]); + } +} + +static void androidReadSession(FFBinder* binder, uint32_t token, FFMediaResult* result, bool saveCover) { + androidReadPackageName(binder, token, result); + + // The length has to be known before the position is placed, so the metadata comes first. + androidReadMetadata(binder, token, result, saveCover); + + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + const char* error = androidCall(binder, token, FF_MEDIA_ANDROID_GET_PLAYBACK_STATE, 0, &reply); + if (error == nullptr && ffBinderReadI32(reply.data, reply.size, 4) != 0) { + FFMediaAndroidReader reader = { .data = reply.data, .size = reply.size, .position = 8 }; + error = androidReadPlaybackState(&reader, result); + } + if (error != nullptr) { + FF_DEBUG("getPlaybackState failed: %s", error); + } +} + +// --------------------------------------------------------------------------------------------- + +// Everything that goes through the media_session service. A reason is returned when it came up empty; +// a song in `media` means it did not. +// +// There is deliberately no uid test in front of this. `getSessions` is refused for an app UID, but that +// 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. +static const char* androidDetectMediaSession(FFMediaResult* media, bool saveCover) { + [[gnu::cleanup(ffBinderClose)]] FFBinder binder = { .fd = -1 }; + const char* error = ffBinderOpen(&binder); + if (error != nullptr) { + return error; + } + + [[gnu::cleanup(ffBinderServiceHandleRelease)]] FFBinderServiceHandle service = { .binder = &binder }; + error = ffBinderLookupService(&binder, FF_MEDIA_ANDROID_SERVICE, FF_BINDER_SM_GET_SERVICE, &service.handle); + if (error != nullptr) { + FF_DEBUG("Looking up \"%s\" failed: %s", FF_MEDIA_ANDROID_SERVICE, error); + return error; + } + + // A phone can have several sessions at once -- a music player and a video one, say -- and only one + // of them is the answer. Which one is decided first, by state alone, so that the metadata and the + // artwork are fetched exactly once, for the winner. + uint32_t tokens[FF_BINDER_MAX_HANDLES]; + uint32_t tokenCount = 0; + { + 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 + ffBinderParcelPutI32(&parcel, 0); // userId: the single user a phone has + + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + error = ffBinderTransact(&binder, service.handle, FF_MEDIA_ANDROID_GET_SESSIONS, 0, &parcel, &reply); + if (error != nullptr) { + return error; + } + if (ffBinderReplyIsStatus(&reply)) { + return "The media session service rejected the request"; + } + const int32_t exception = ffBinderReadI32(reply.data, reply.size, 0); + 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. + 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"; + } + + // The tokens arrive as flat binder objects, which ffBinderTransact() collects in payload order + // -- the same order the descriptors of a metadata reply arrive in, which is what makes the two + // line up without walking the list itself. + tokenCount = reply.handleCount; + if (tokenCount > FF_BINDER_MAX_HANDLES) { + tokenCount = FF_BINDER_MAX_HANDLES; + } + for (uint32_t i = 0; i < tokenCount; i++) { + tokens[i] = reply.handles[i]; + } + } + + if (tokenCount == 0) { + return "No active media session"; + } + + uint32_t best = 0; + uint32_t bestScore = 0; + for (uint32_t i = 0; i < tokenCount; i++) { + FFBinderReply reply = ffBinderReplyCreate(s_reply, sizeof(s_reply)); + if (androidCall(&binder, tokens[i], FF_MEDIA_ANDROID_GET_PLAYBACK_STATE, 0, &reply) != nullptr) { + continue; + } + // The presence marker first: a session with no state yet answers a null parcelable, and its + // state field is not there to be read. + const int32_t state = ffBinderReadI32(reply.data, reply.size, 4) != 0 ? ffBinderReadI32(reply.data, reply.size, 8) : 0; + const uint32_t score = androidPlaybackStateScore(state); + if (score > bestScore) { + bestScore = score; + best = i; + } + } + + androidReadSession(&binder, tokens[best], media, saveCover); + + // The tokens were acquired by ffBinderTransact() and are given back one by one; releasing them is + // what keeps a short-lived fastfetch from holding a session open for the life of the process. + for (uint32_t i = 0; i < tokenCount; i++) { + FFBinderServiceHandle token = { .binder = &binder, .handle = tokens[i] }; + ffBinderServiceHandleRelease(&token); + } + + return nullptr; +} + +// --------------------------------------------------------------------------------------------- + +// The MPRIS implementation in media_linux.c, whose entry point is renamed on Android because this file +// owns ffDetectMediaImpl -- the same arrangement as wallpaper_linux.c and terminalfont_linux.c. +void ffDetectMediaLinuxImpl(FFMediaResult* media, bool saveCover); + +void ffDetectMediaImpl(FFMediaResult* media, bool saveCover) { + // The binder route below reads Android's own now-playing session, which is the one the phone is + // playing only while SurfaceFlinger is the display server. A Termux:X11 or Wayland session runs a + // real compositor on top of the device, and what is playing there belongs to that desktop, not to + // Android -- so that case is handed to the Linux implementation, which knows the MPRIS bus. Only + // one of the two answers, never merged. + const FFDisplayServerResult* wm = ffConnectDisplayServer(); + if (!ffStrbufIgnCaseEqualS(&wm->wmProtocolName, FF_WM_PROTOCOL_SURFACEFLINGER)) { + FF_DEBUG("The display server is \"%s\", so the Linux implementation answers", wm->wmProtocolName.chars); + ffDetectMediaLinuxImpl(media, saveCover); + return; + } + + const char* error = androidDetectMediaSession(media, saveCover); + if (media->song.length > 0) { + return; // the phone's own now-playing session answered + } + + // Nothing came out of the media_session service, so try the session bus. That is worth trying even + // with SurfaceFlinger on screen: the service refuses an app UID outright, and a player inside a + // chroot is not an Android media session at all, so nothing else here would ever see it. + // + // The reason is copied out first, because the fallback writes to media->error itself. + char sessionError[160] = ""; + if (error != nullptr) { + snprintf(sessionError, sizeof(sessionError), "%s", error); + } + FF_DEBUG("The media session gave nothing (%s), falling back to MPRIS", sessionError[0] != '\0' ? sessionError : "no session"); + + // Artwork without a song to go with it: the file belongs to the track MPRIS is about to report, so + // it is removed here rather than at exit, where media.c would by then have forgotten the path. + if (media->cover.length > 0) { + ffRemoveFile(media->cover.chars); + ffStrbufClear(&media->cover); + media->removeCoverAfterUse = false; + } + + ffStrbufClear(&media->error); + ffDetectMediaLinuxImpl(media, saveCover); + + // The phone is what was asked about, so its reason is the one to print: it names something a user + // can act on, while "Failed to connect to DBus" only explains why the second opinion was never + // taken -- and most phones have no session bus at all, which would make that the usual answer. + // MPRIS never reports anything when it merely finds no player, so a silent fallback here means + // nothing is playing anywhere. + if (media->song.length == 0) { + ffStrbufSetS(&media->error, sessionError[0] != '\0' ? sessionError : "No active media session"); + } +} diff --git a/src/detection/media/media_linux.c b/src/detection/media/media_linux.c index dcd4a05f5..6ccc0b4f7 100644 --- a/src/detection/media/media_linux.c +++ b/src/detection/media/media_linux.c @@ -277,7 +277,17 @@ static const char* getMedia(FFMediaResult* result) { #endif -void ffDetectMediaImpl(FFMediaResult* media, bool saveCover) { +// Android compiles this file next to media_android.c, which owns ffDetectMediaImpl and calls this one +// when the media_session service has nothing to say -- an app UID is refused by that service, while the +// session bus a desktop environment brings up is open to every process in it. Same arrangement as +// wallpaper_linux.c and terminalfont_linux.c. +void +#ifdef __ANDROID__ +ffDetectMediaLinuxImpl +#else +ffDetectMediaImpl +#endif + (FFMediaResult* media, bool saveCover) { FF_UNUSED(saveCover); // We don't save the cover to a file for Mpris implementation #ifdef FF_HAVE_DBUS const char* error = getMedia(media);