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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user