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:
@@ -62,8 +62,18 @@ control's target speed (0.0 when only polled); `0x2140A15A` moved between 3 and
|
|||||||
times they name is a lead; say how sure it is, and that it is not confirmed until a second
|
times they name is a lead; say how sure it is, and that it is not confirmed until a second
|
||||||
drive shows the same.
|
drive shows the same.
|
||||||
|
|
||||||
|
## Music-sources checks
|
||||||
|
|
||||||
|
`fetch-logs` also brings `media-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check music sources",
|
||||||
|
`MediaProbe`). Three parts: audio players (use, app, state), active media sessions, and every
|
||||||
|
media browser service with `connected` / `refused or failed` / `no answer`, then its state and
|
||||||
|
song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size). CarPlay's is
|
||||||
|
`com.allgo.remoteui.mediabrowserservice/...RemoteUIMediaBrowserService`. Ask what was playing
|
||||||
|
when the check ran.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Recordings are the car's data: `logs/` and `/signal-recording-*.txt` are gitignored. Never
|
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
||||||
|
`/media-probe-*.txt` are gitignored. Never
|
||||||
commit, publish or upload them anywhere.
|
commit, publish or upload them anywhere.
|
||||||
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
||||||
|
|||||||
@@ -45,3 +45,4 @@ mise.local.toml
|
|||||||
# The car's own recordings (fetch-logs, or copied off a USB stick): its data, not the code's.
|
# The car's own recordings (fetch-logs, or copied off a USB stick): its data, not the code's.
|
||||||
/logs/
|
/logs/
|
||||||
/signal-recording-*.txt
|
/signal-recording-*.txt
|
||||||
|
/media-probe-*.txt
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| Live data | on/off, ABRP API key and user token, upload frequency |
|
||||||
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||||
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
||||||
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players, media sessions and every media browser service, CarPlay's among them, with their song; read only, saved and sent like a recording), update log (every install step, with the platform's words), upload log |
|
||||||
|
|
||||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
|
|||||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||||
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
|
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
|
||||||
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
|
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
|
||||||
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; sent to your server, and saved to a USB stick too), update log (every install step), upload log |
|
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; sent to your server, and saved to a USB stick too), music-sources check (where the head unit tells what is playing, CarPlay included; read only), update log (every install step), upload log |
|
||||||
|
|
||||||
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
|
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
|
||||||
voice key** on the steering wheel and the app comes to the front (switch on the Settings
|
voice key** on the steering wheel and the app comes to the front (switch on the Settings
|
||||||
|
|||||||
@@ -140,6 +140,34 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-10-28 — App 2.42.0: **music-sources check** on the Logs tab (`MediaProbe`,
|
||||||
|
`MediaProbeText`; owner: "is it possible to show playing music in our dashboard as well?", then
|
||||||
|
"yes start the probe"). Before a now-playing display, it finds where the head unit tells what is
|
||||||
|
playing, read only: the players Android's audio service reports (use, app, state), the active
|
||||||
|
media sessions (`MediaSessionManager.getActiveSessions`, MEDIA_CONTENT_CONTROL, which the system
|
||||||
|
user has) with their song, and every media browser service, each connected to, its session read
|
||||||
|
and disconnected. CarPlay's is tried even when not listed:
|
||||||
|
`com.allgo.remoteui.mediabrowserservice/.RemoteUIMediaBrowserService`, which the car's own media
|
||||||
|
library (`com.saicmotor.sdk.media…MediaPlayControlManager` in Allgo's APK) reads CarPlay's title,
|
||||||
|
artist, album and cover from through a plain `MediaBrowser`. Nothing of Allgo's is changed and
|
||||||
|
nothing is played, paused or skipped. The report (`media-probe-YYYYMMDD-HHMMSS.txt`) goes where
|
||||||
|
a recording goes, through the shared `SignalRecorder.saveCopies`: the app, a USB stick, the
|
||||||
|
server (`fetch-logs`). Unit tests for the text; tests and lint pass. Emulator (Android 9): the
|
||||||
|
players are read with their app and state (the system APIs answer the system user), the
|
||||||
|
sessions are readable (none there, no SecurityException), the AOSP music app's browser service
|
||||||
|
connects and is read, CarPlay's (not installed there) is "refused or failed", and the report
|
||||||
|
lands in the outbox. **Not tried on the car.** Unknown until then: whether CarPlay's service
|
||||||
|
lets the app connect, and whether the radio, Bluetooth and USB sources publish a session.
|
||||||
|
- 2026-10-28 — First recordings over the server (14:19 and 14:23, app 2.41.0, ACC on, the set
|
||||||
|
speed raised a few times): the server path works, and listening works in the car (249
|
||||||
|
assistance properties, 0 refused, 76 and 81 change events). `0x2160A15B` (`AAD_EPTCCTRGTSPD`)
|
||||||
|
stays 0.0 even while listened to: the car does not fill it. `0x2140A15A` (3/7, a few 0s) and
|
||||||
|
`0x2160A1A3`/`A1A4` (always equal, 1.4–5.3, now and then 0.1–0.2 down) do not move with the
|
||||||
|
key presses. New lead: steering-wheel keys **297** (2× at 14:20:09, 2× at 14:20:12) and **300**
|
||||||
|
(14:20:05, 2× at 14:20:40), the car speeding up 27 → 49 km/h after them; key 3 is Home. The
|
||||||
|
14:23 recording has no key at all. Not confirmed: which buttons these are. Also seen:
|
||||||
|
`0x21401553`–`1556` ≈ 256 are tyre pressures and `0x2140155B`–`155E` (18–24) tyre
|
||||||
|
temperatures, not the set speed.
|
||||||
- 2026-10-27 — Skill `car-logs` (`.claude/skills/car-logs/SKILL.md`; owner: "can we write a skill for downloading the logs?"): fetch with `fetch-logs` without ever showing the token, what its errors mean, how a recording is laid out, the cuts that find a lead, the leads so far, and that recordings are never committed. Its commands tried on the 08:52 recording.
|
- 2026-10-27 — Skill `car-logs` (`.claude/skills/car-logs/SKILL.md`; owner: "can we write a skill for downloading the logs?"): fetch with `fetch-logs` without ever showing the token, what its errors mean, how a recording is laid out, the cuts that find a lead, the leads so far, and that recordings are never committed. Its commands tried on the 08:52 recording.
|
||||||
- 2026-10-27 — Server 2.9.0 pushed (`sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48`, pinned in `k8s/server.yaml`); app 2.41.0 published (`car-2.41.0`).
|
- 2026-10-27 — Server 2.9.0 pushed (`sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48`, pinned in `k8s/server.yaml`); app 2.41.0 published (`car-2.41.0`).
|
||||||
- 2026-10-27 — App 2.41.0, server 2.9.0: **recordings reach the server** (owner: "can we make
|
- 2026-10-27 — App 2.41.0, server 2.9.0: **recordings reach the server** (owner: "can we make
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 150
|
versionCode 151
|
||||||
versionName "2.41.0"
|
versionName "2.42.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// 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.
|
// 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);
|
else SignalRecorder.start(this);
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
});
|
});
|
||||||
|
logPane.findViewById(R.id.media_probe_button).setOnClickListener(v -> {
|
||||||
|
MediaProbe.start(this);
|
||||||
|
refreshMediaProbe();
|
||||||
|
});
|
||||||
|
|
||||||
setUpPager();
|
setUpPager();
|
||||||
// Back on the page that was open when the activity was recreated (accent colour change).
|
// 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));
|
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() {
|
private void refreshCallLog() {
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
|
refreshMediaProbe();
|
||||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||||
// Update steps first: after a failed install, they are what the driver came here for.
|
// Update steps first: after a failed install, they are what the driver came here for.
|
||||||
java.util.List<String> updates = UpdateLog.lines(this);
|
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("");
|
||||||
text.add("Everything, in order:");
|
text.add("Everything, in order:");
|
||||||
text.addAll(rec.lines());
|
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)
|
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||||
.format(new Date(startedMs)) + ".txt";
|
.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);
|
File[] dirs = app.getExternalFilesDirs(null);
|
||||||
for (File dir : dirs == null ? new File[0] : dirs) {
|
for (File dir : dirs == null ? new File[0] : dirs) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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" />
|
<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" />
|
<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>
|
</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">
|
<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" />
|
<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" />
|
<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" />
|
||||||
|
|||||||
@@ -228,6 +228,11 @@
|
|||||||
<string name="btn_signal_stop">Stop recording</string>
|
<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_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="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_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="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>
|
<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<!-- Unstable channel only. The stable build has no self-update path, so neither the install
|
<!-- 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. -->
|
permission nor the session result receiver belongs in the main manifest. -->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:sharedUserId="android.uid.system">
|
android:sharedUserId="android.uid.system">
|
||||||
|
|
||||||
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
|
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
|
||||||
@@ -11,6 +12,11 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
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>
|
<application>
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
Reference in New Issue
Block a user