In the car, 2.42.0's check stopped the Bluetooth music and raised a media-access prompt: binding every media browser service started the car radio and the Local Media Player. The services are now only listed; the active media sessions already carry the song of Bluetooth and CarPlay without any connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
284 lines
11 KiB
Java
284 lines
11 KiB
Java
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.session.MediaController;
|
||
import android.media.session.MediaSessionManager;
|
||
import android.media.session.PlaybackState;
|
||
import android.service.media.MediaBrowserService;
|
||
|
||
import java.text.SimpleDateFormat;
|
||
import java.util.ArrayList;
|
||
import java.util.Date;
|
||
import java.util.List;
|
||
import java.util.Locale;
|
||
import java.util.TreeSet;
|
||
|
||
/**
|
||
* 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, listed but not connected to (2.42.1:
|
||
* a connection is not harmless, see {@link #browsers}).</li>
|
||
* </ol>
|
||
* No other app's service is bound; 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";
|
||
|
||
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, listed only. 2.42.0 connected to each, as the car's media
|
||
* library does; on the car that was not harmless: the Bluetooth music stopped (the AOSP car
|
||
* radio's service, `com.android.car.radio/.RadioService`, answered "connecting", most likely
|
||
* taking the audio) and the Local Media Player asked for access to media. The active sessions
|
||
* above already carry the song of whatever plays, CarPlay's and Bluetooth's included, so no
|
||
* service is bound any more.
|
||
*/
|
||
private static void browsers(Context app, List<String> out) {
|
||
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "), listed, not connected to:");
|
||
try {
|
||
List<ResolveInfo> found = app.getPackageManager()
|
||
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
|
||
if (found == null || found.isEmpty()) {
|
||
out.add(" none");
|
||
return;
|
||
}
|
||
for (ResolveInfo ri : found) {
|
||
if (ri.serviceInfo == null) continue;
|
||
out.add(" " + new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name)
|
||
.flattenToShortString());
|
||
}
|
||
} catch (RuntimeException e) {
|
||
out.add(" list not readable: " + e.getClass().getSimpleName());
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|