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
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 150
versionName "2.41.0"
versionCode 151
versionName "2.42.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -408,6 +408,10 @@ public class MainActivity extends AppCompatActivity {
else SignalRecorder.start(this);
refreshSignalRecorder();
});
logPane.findViewById(R.id.media_probe_button).setOnClickListener(v -> {
MediaProbe.start(this);
refreshMediaProbe();
});
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
@@ -2325,8 +2329,20 @@ public class MainActivity extends AppCompatActivity {
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
/** The music-sources check: its button and its last report. */
private void refreshMediaProbe() {
MaterialButton button = logPane.findViewById(R.id.media_probe_button);
button.setEnabled(!MediaProbe.running());
TextView text = logPane.findViewById(R.id.media_probe_text);
java.util.List<String> status = MediaProbe.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
if (status.size() > 120) status = status.subList(0, 120);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
private void refreshCallLog() {
refreshSignalRecorder();
refreshMediaProbe();
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
// Update steps first: after a failed install, they are what the driver came here for.
java.util.List<String> updates = UpdateLog.lines(this);
@@ -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;
}
}
@@ -0,0 +1,88 @@
package com.evsuite.abrp;
import java.util.Locale;
/**
* The words of {@link MediaProbe}'s report: Android's numbers for playback and audio states as
* names, and text from other apps made safe for one line. Android-free and unit-tested.
*/
final class MediaProbeText {
/** Longest text kept from another app (a title, an artist). */
static final int MAX_TEXT = 120;
private MediaProbeText() {}
/** Another app's text on one line: control characters out, at most {@link #MAX_TEXT}. */
static String clean(CharSequence text) {
if (text == null) return "(none)";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < text.length() && sb.length() < MAX_TEXT; i++) {
char c = text.charAt(i);
sb.append(Character.isISOControl(c) ? ' ' : c);
}
String s = sb.toString().trim();
if (text.length() > MAX_TEXT) s += "…";
return s.isEmpty() ? "(empty)" : s;
}
/** {@code android.media.session.PlaybackState} STATE_*. */
static String playbackState(int state) {
switch (state) {
case 0: return "none";
case 1: return "stopped";
case 2: return "paused";
case 3: return "playing";
case 4: return "fast forwarding";
case 5: return "rewinding";
case 6: return "buffering";
case 7: return "error";
case 8: return "connecting";
case 9: return "skipping to previous";
case 10: return "skipping to next";
case 11: return "skipping to queue item";
default: return "state " + state;
}
}
/** {@code android.media.AudioAttributes} USAGE_*. */
static String usage(int usage) {
switch (usage) {
case 0: return "unknown";
case 1: return "media";
case 2: return "voice call";
case 3: return "call signalling";
case 4: return "alarm";
case 5: return "notification";
case 6: return "ringtone";
case 7: case 8: case 9: case 10: return "notification (" + usage + ")";
case 11: return "accessibility";
case 12: return "navigation guidance";
case 13: return "sonification";
case 14: return "game";
case 15: return "virtual source";
case 16: return "assistant";
default: return "usage " + usage;
}
}
/** {@code android.media.AudioPlaybackConfiguration} PLAYER_STATE_*. */
static String playerState(int state) {
switch (state) {
case -1: return "unknown";
case 0: return "released";
case 1: return "idle";
case 2: return "started";
case 3: return "paused";
case 4: return "stopped";
default: return "state " + state;
}
}
/** A position or duration in milliseconds as m:ss; negative (unknown) as "?". */
static String duration(long ms) {
if (ms < 0) return "?";
long s = ms / 1000;
return String.format(Locale.US, "%d:%02d", s / 60, s % 60);
}
}
@@ -167,10 +167,19 @@ final class SignalRecorder {
text.add("");
text.add("Everything, in order:");
text.addAll(rec.lines());
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
return saveCopies(app, FILE, name, text);
}
/**
* Writes a report to the app's storage ({@code appFile}), to the app's folder on every USB
* stick plugged in ({@code name}) and to the car server; says where it went. Shared with
* {@link MediaProbe}.
*/
static String saveCopies(Context app, String appFile, String name, List<String> text) {
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), appFile), text)) places.add("app");
File[] dirs = app.getExternalFilesDirs(null);
for (File dir : dirs == null ? new File[0] : dirs) {
try {
+7
View File
@@ -8,6 +8,13 @@
<com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout>
<!-- The music-sources check (MediaProbe): where the head unit tells what is playing. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_media_probe" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/media_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.button.MaterialButton android:id="@+id/media_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_media_probe" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/media_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
+5
View File
@@ -228,6 +228,11 @@
<string name="btn_signal_stop">Stop recording</string>
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="label_media_probe">Music sources</string>
<string name="media_probe_hint">Finds where the head unit tells what is playing: the playback Android reports, the media sessions apps publish, and every media service of the head unit, CarPlay\'s among them, each with its song. Play something first (CarPlay, then the radio, Bluetooth or USB) and check once for each. Like a recording, the result goes to your server and to a USB stick. It only reads: nothing is played, paused or skipped.</string>
<string name="btn_media_probe">Check music sources</string>
<string name="media_probe_running">Checking… (up to a minute)</string>
<string name="media_probe_done">Check from %1$s, %2$s:</string>
<string name="log_outbox_sent">Sent to the server.</string>
<string name="log_outbox_waiting">Not on the server yet (%1$d waiting: %2$s); it is sent again every 2 minutes.</string>
<string name="call_log_empty">No upload attempted yet.</string>
@@ -0,0 +1,38 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MediaProbeTextTest {
@Test
public void anotherAppsTextStaysOnOneLine() {
assertEquals("Song Title", MediaProbeText.clean("Song\nTitle"));
assertEquals("(none)", MediaProbeText.clean(null));
assertEquals("(empty)", MediaProbeText.clean(" \t"));
String longText = new String(new char[500]).replace('\0', 'a');
String cleaned = MediaProbeText.clean(longText);
assertEquals(MediaProbeText.MAX_TEXT + 1, cleaned.length());
assertTrue(cleaned.endsWith("…"));
}
@Test
public void androidsNumbersHaveNames() {
assertEquals("playing", MediaProbeText.playbackState(3));
assertEquals("paused", MediaProbeText.playbackState(2));
assertEquals("state 99", MediaProbeText.playbackState(99));
assertEquals("media", MediaProbeText.usage(1));
assertEquals("navigation guidance", MediaProbeText.usage(12));
assertEquals("assistant", MediaProbeText.usage(16));
assertEquals("started", MediaProbeText.playerState(2));
}
@Test
public void durationsAsMinutes() {
assertEquals("3:25", MediaProbeText.duration(205_400));
assertEquals("0:00", MediaProbeText.duration(0));
assertEquals("?", MediaProbeText.duration(-1));
}
}
+6
View File
@@ -2,6 +2,7 @@
<!-- Unstable channel only. The stable build has no self-update path, so neither the install
permission nor the session result receiver belongs in the main manifest. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserId="android.uid.system">
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
@@ -11,6 +12,11 @@
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Lists other apps' media sessions (MediaProbe, the Logs tab's music-sources check).
A platform permission: the system user has it; elsewhere the check says it cannot. -->
<uses-permission
android:name="android.permission.MEDIA_CONTENT_CONTROL"
tools:ignore="ProtectedPermissions" />
<application>
<provider