App 2.42.0: music-sources check on the Logs tab

Before a now-playing display, find where the head unit tells what is
playing, read only: the players Android reports, the active media
sessions, and every media browser service with its song, CarPlay's
(com.allgo.remoteui.mediabrowserservice) among them. The report is saved
and sent like a signal recording (shared SignalRecorder.saveCopies).
Also the findings of the first two recordings from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-28 09:31:45 +01:00
co-authored by Claude Opus 5.5
parent 8596951cef
commit 6563b2c4e8
14 changed files with 581 additions and 7 deletions
@@ -0,0 +1,366 @@
package com.evsuite.abrp;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.media.AudioPlaybackConfiguration;
import android.media.MediaMetadata;
import android.media.browse.MediaBrowser;
import android.media.session.MediaController;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.os.Handler;
import android.os.HandlerThread;
import android.service.media.MediaBrowserService;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* The music-sources check on the Logs tab: where the head unit tells what is playing, before
* the Overview shows it. Three places, all read only:
* <ol>
* <li>the playback Android's audio service reports (which app plays, for what use);</li>
* <li>the active media sessions ({@code MediaSessionManager.getActiveSessions}, which needs
* MEDIA_CONTENT_CONTROL: the car build has it as the system user), with their song and
* state;</li>
* <li>every media browser service on the head unit, CarPlay's
* ({@link #CARPLAY}, where the car's own media library reads CarPlay's song) among them:
* connected to as the car's media library does, its session's song and state read, and
* disconnected.</li>
* </ol>
* Nothing is played, paused or skipped. The report goes where a signal recording goes
* ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
*/
final class MediaProbe {
static final String FILE = "media_probe.txt";
/** CarPlay's song, as {@code com.saicmotor.sdk.media…MediaPlayControlManager} reads it. */
static final ComponentName CARPLAY = new ComponentName("com.allgo.remoteui.mediabrowserservice",
"com.allgo.remoteui.mediabrowserservice.RemoteUIMediaBrowserService");
static final long CONNECT_TIMEOUT_MS = 8_000L;
/** More browser services than this are listed but not connected to. */
static final int MAX_BROWSERS = 12;
private static volatile boolean running;
private static long startedMs;
private static List<String> report = new ArrayList<>();
private static String result;
private MediaProbe() {}
static synchronized boolean running() {
return running;
}
static synchronized void start(Context context) {
if (running) return;
Context app = context.getApplicationContext();
running = true;
startedMs = System.currentTimeMillis();
result = null;
report = new ArrayList<>();
new Thread(() -> probe(app), "media-probe").start();
}
private static void probe(Context app) {
List<String> out = new ArrayList<>();
out.add("Media probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
String saved;
try {
out.add("");
audio(app, out);
out.add("");
sessions(app, out);
out.add("");
browsers(app, out);
} catch (Throwable t) {
out.add("probe stopped: " + t.getClass().getSimpleName());
} finally {
String name = "media-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
saved = SignalRecorder.saveCopies(app, FILE, name, out);
synchronized (MediaProbe.class) {
report = out;
result = saved;
running = false;
}
}
}
/** Who plays audio, as Android's audio service sees it. */
private static void audio(Context app, List<String> out) {
out.add("Audio playing (AudioManager):");
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
if (am == null) {
out.add(" no audio service");
return;
}
try {
out.add(" music active: " + am.isMusicActive() + ", mode " + am.getMode());
} catch (RuntimeException e) {
out.add(" music active: unreadable (" + e.getClass().getSimpleName() + ")");
}
List<AudioPlaybackConfiguration> players;
try {
players = am.getActivePlaybackConfigurations();
} catch (RuntimeException e) {
out.add(" players: unreadable (" + e.getClass().getSimpleName() + ")");
return;
}
if (players == null || players.isEmpty()) {
out.add(" players: none");
return;
}
PackageManager pm = app.getPackageManager();
for (AudioPlaybackConfiguration p : players) {
StringBuilder line = new StringBuilder(" player: ");
try {
line.append(MediaProbeText.usage(p.getAudioAttributes().getUsage()));
} catch (RuntimeException e) {
line.append("usage ?");
}
// The app and state are system APIs: read when the platform lets the system user.
Integer uid = hiddenInt(p, "getClientUid");
if (uid != null) line.append(", uid ").append(uid).append(' ').append(packages(pm, uid));
Integer state = hiddenInt(p, "getPlayerState");
if (state != null) line.append(", ").append(MediaProbeText.playerState(state));
if (uid == null && state == null) line.append(" — ").append(MediaProbeText.clean(p.toString()));
out.add(line.toString());
}
}
private static Integer hiddenInt(Object target, String method) {
try {
Object v = target.getClass().getMethod(method).invoke(target);
return v instanceof Integer ? (Integer) v : null;
} catch (Throwable t) {
return null;
}
}
private static String packages(PackageManager pm, int uid) {
try {
String[] names = pm.getPackagesForUid(uid);
return names == null ? "(no package)" : String.join(", ", names);
} catch (RuntimeException e) {
return "(package unreadable)";
}
}
/** The media sessions apps publish, newest first, with their song. */
private static void sessions(Context app, List<String> out) {
out.add("Active media sessions (MediaSessionManager):");
List<MediaController> controllers;
try {
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
controllers = msm == null ? null : msm.getActiveSessions(null);
} catch (Throwable t) {
out.add(" not readable: " + t.getClass().getSimpleName() + " " + MediaProbeText.clean(t.getMessage()));
return;
}
if (controllers == null || controllers.isEmpty()) {
out.add(" none");
return;
}
for (MediaController c : controllers) {
out.add(" session of " + safe(c::getPackageName));
describe(c, out, " ");
}
}
/** Every media browser service, each connected to, read and disconnected. */
private static void browsers(Context app, List<String> out) {
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "):");
Set<ComponentName> services = new LinkedHashSet<>();
try {
List<ResolveInfo> found = app.getPackageManager()
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
for (ResolveInfo ri : found == null ? new ArrayList<ResolveInfo>() : found) {
if (ri.serviceInfo != null) {
services.add(new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name));
}
}
} catch (RuntimeException e) {
out.add(" list not readable: " + e.getClass().getSimpleName());
}
if (!services.contains(CARPLAY)) out.add(" CarPlay's service is not listed; tried all the same");
services.add(CARPLAY);
HandlerThread thread = new HandlerThread("media-probe-browser");
thread.start();
try {
Handler handler = new Handler(thread.getLooper());
int n = 0;
for (ComponentName service : services) {
if (++n > MAX_BROWSERS) {
out.add(" " + service.flattenToShortString() + ": not tried (limit)");
continue;
}
browse(app, handler, service, out);
}
} finally {
thread.quitSafely();
}
}
/** Connects to one browser service on {@code handler}'s thread and reads its session. */
private static void browse(Context app, Handler handler, ComponentName service, List<String> out) {
out.add(" " + service.flattenToShortString() + ":");
CountDownLatch answered = new CountDownLatch(1);
AtomicReference<String> outcome = new AtomicReference<>();
AtomicReference<MediaBrowser> browser = new AtomicReference<>();
boolean posted = handler.post(() -> {
try {
MediaBrowser b = new MediaBrowser(app, service, new MediaBrowser.ConnectionCallback() {
@Override public void onConnected() {
outcome.compareAndSet(null, "connected");
answered.countDown();
}
@Override public void onConnectionFailed() {
outcome.compareAndSet(null, "refused or failed");
answered.countDown();
}
@Override public void onConnectionSuspended() {
outcome.compareAndSet(null, "suspended");
answered.countDown();
}
}, null);
browser.set(b);
b.connect();
} catch (Throwable t) {
outcome.compareAndSet(null, "could not try: " + t.getClass().getSimpleName());
answered.countDown();
}
});
try {
if (!posted || !answered.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
out.add(" no answer in " + CONNECT_TIMEOUT_MS / 1000 + " s");
return;
}
out.add(" " + outcome.get());
MediaBrowser b = browser.get();
if (!"connected".equals(outcome.get()) || b == null) return;
try {
out.add(" root: " + MediaProbeText.clean(b.getRoot()));
describe(new MediaController(app, b.getSessionToken()), out, " ");
} catch (Throwable t) {
out.add(" session not readable: " + t.getClass().getSimpleName());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
out.add(" interrupted");
} finally {
handler.post(() -> {
try {
MediaBrowser b = browser.get();
if (b != null) b.disconnect();
} catch (Throwable ignored) {
// already gone
}
});
}
}
/** A session's state and everything its song's metadata holds. */
private static void describe(MediaController c, List<String> out, String indent) {
try {
PlaybackState s = c.getPlaybackState();
out.add(indent + "state: " + (s == null ? "(none)"
: MediaProbeText.playbackState(s.getState()) + ", at "
+ MediaProbeText.duration(s.getPosition()) + ", actions 0x"
+ Long.toHexString(s.getActions())));
} catch (Throwable t) {
out.add(indent + "state: unreadable (" + t.getClass().getSimpleName() + ")");
}
try {
MediaController.PlaybackInfo info = c.getPlaybackInfo();
if (info != null) {
out.add(indent + "plays " + (info.getPlaybackType() == MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE
? "remotely" : "locally") + ", for "
+ MediaProbeText.usage(info.getAudioAttributes().getUsage()));
}
} catch (Throwable ignored) {
// Not every session says.
}
MediaMetadata m;
try {
m = c.getMetadata();
} catch (Throwable t) {
out.add(indent + "song: unreadable (" + t.getClass().getSimpleName() + ")");
return;
}
if (m == null) {
out.add(indent + "song: (no metadata)");
return;
}
for (String key : new TreeSet<>(m.keySet())) {
String shortKey = key.startsWith("android.media.metadata.")
? key.substring("android.media.metadata.".length()) : key;
out.add(indent + shortKey + ": " + value(m, key));
}
}
/** One metadata value: a picture by its size, text cleaned, anything else as a number. */
private static String value(MediaMetadata m, String key) {
try {
Bitmap bitmap = m.getBitmap(key);
if (bitmap != null) return "picture " + bitmap.getWidth() + "×" + bitmap.getHeight();
} catch (Throwable ignored) {
// not a picture
}
try {
CharSequence text = m.getText(key);
if (text != null) return MediaProbeText.clean(text);
} catch (Throwable ignored) {
// not text
}
try {
if (key.equals(MediaMetadata.METADATA_KEY_DURATION)) {
return MediaProbeText.duration(m.getLong(key));
}
if (m.getRating(key) != null) return "rating";
return Long.toString(m.getLong(key));
} catch (Throwable t) {
return "?";
}
}
private static String safe(java.util.concurrent.Callable<String> read) {
try {
return MediaProbeText.clean(read.call());
} catch (Throwable t) {
return "?";
}
}
/** What the Logs tab shows: the state, then the report. */
static synchronized List<String> status(Context context) {
List<String> lines = new ArrayList<>();
if (startedMs == 0) return lines;
if (running) {
lines.add(context.getString(R.string.media_probe_running));
return lines;
}
lines.add(context.getString(R.string.media_probe_done, SignalRecording.time(startedMs),
result == null ? "…" : result));
String sent = LogOutbox.status(context);
if (sent != null) lines.add(sent);
lines.addAll(report.subList(Math.min(1, report.size()), report.size()));
return lines;
}
}