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: *
    *
  1. the playback Android's audio service reports (which app plays, for what use);
  2. *
  3. 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;
  4. *
  5. every media browser service on the head unit, listed but not connected to (2.42.1: * a connection is not harmless, see {@link #browsers}).
  6. *
* 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; } }