Media (Android): adds support

This commit is contained in:
李通洲
2026-09-25 00:59:07 +08:00
parent 0e04ebdf63
commit d223b04ad8
4 changed files with 979 additions and 9 deletions
+2 -1
View File
@@ -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 `<redacted>`. (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)
+10 -7
View File
@@ -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)
+956
View File
@@ -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 <android/bitmap.h>
#include <android/data_space.h>
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
// 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");
}
}
+11 -1
View File
@@ -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);