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:
*
* - the playback Android's audio service reports (which app plays, for what use);
* - 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;
* - every media browser service on the head unit, listed but not connected to (2.42.1:
* a connection is not harmless, see {@link #browsers}).
*
* 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 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 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 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 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 out) {
out.add("Active media sessions (MediaSessionManager):");
List 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 out) {
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "), listed, not connected to:");
try {
List 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 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 read) {
try {
return MediaProbeText.clean(read.call());
} catch (Throwable t) {
return "?";
}
}
/** What the Logs tab shows: the state, then the report. */
static synchronized List status(Context context) {
List 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;
}
}