App 2.19.1: Foreground apps diagnostic on the Logs tab

Read-only: recent foreground apps (usage events), open tasks,
policy_control and the status bar height, for finding the CarPlay
package before a full-screen experiment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 20:30:54 +02:00
co-authored by Claude Opus 5.5
parent 461f9d5578
commit 209a231511
9 changed files with 345 additions and 7 deletions
+9
View File
@@ -118,6 +118,15 @@ 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
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).
- 2026-10-23 — App 2.19.1: "Foreground apps" card on the Logs tab, step 1 of the CarPlay
full-screen experiment. It reads which apps came to the front in the last 30 minutes (usage
events, open to the system user without a declared permission), the open tasks, the
`policy_control` global setting and the status bar height — on opening the tab, on returning to
it and on Refresh, never in the background. It changes nothing. On the emulator (car build,
system user) it named Settings, the clock, the calculator and the launcher with times, and the
page still fits 1778×640 with every line filled. The stable build says it cannot read them. Not
tried on the car; the CarPlay package name is what this is for. Next: a switch that sets
`policy_control` to `immersive.full=<that package>`, on approval.
- 2026-10-19 — `k8s/agent-access/`: a service account `mg4-agent` for a coding agent, limited to
the `mg4` namespace. Read-only role by default; an optional deploy role. Neither may touch
Secrets, exec, port-forward or RBAC; the deploy role cannot delete PVCs. `make-kubeconfig.sh`
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 99
versionName "2.19.0"
versionCode 100
versionName "2.19.1"
// 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.
@@ -0,0 +1,64 @@
package com.evsuite.abrp;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
/**
* Which app was in front, and when: the lines of the Logs tab's "Foreground apps" card.
*
* Built from the platform's foreground events, oldest first. Consecutive events for the same
* screen collapse into one, the newest comes first, and an activity in its own package is
* shortened to ".Name" so the line stays readable. Pure Java, so it is unit-tested.
*/
final class ForegroundHistory {
/** One move to the foreground. className may be null when the platform gives none. */
static final class Event {
final long timeMs;
final String packageName;
final String className;
Event(long timeMs, String packageName, String className) {
this.timeMs = timeMs;
this.packageName = packageName;
this.className = className;
}
}
private ForegroundHistory() {}
/** At most {@code limit} lines, newest first. */
static List<String> lines(List<Event> oldestFirst, int limit, TimeZone zone) {
List<Event> collapsed = new ArrayList<>();
for (Event e : oldestFirst) {
if (e == null || e.packageName == null || e.packageName.isEmpty()) continue;
Event last = collapsed.isEmpty() ? null : collapsed.get(collapsed.size() - 1);
if (last != null && last.packageName.equals(e.packageName)
&& equal(last.className, e.className)) continue;
collapsed.add(e);
}
SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss", Locale.ROOT);
time.setTimeZone(zone);
List<String> out = new ArrayList<>();
for (int i = collapsed.size() - 1; i >= 0 && out.size() < limit; i--) {
Event e = collapsed.get(i);
String screen = shortClass(e.packageName, e.className);
out.add(time.format(e.timeMs) + " " + e.packageName + (screen == null ? "" : " " + screen));
}
return out;
}
/** ".Name" for an activity inside its own package, the full name otherwise. */
static String shortClass(String packageName, String className) {
if (className == null || className.isEmpty()) return null;
if (className.startsWith(packageName + ".")) return className.substring(packageName.length());
return className;
}
private static boolean equal(String a, String b) {
return a == null ? b == null : a.equals(b);
}
}
@@ -0,0 +1,110 @@
package com.evsuite.abrp;
import android.annotation.SuppressLint;
import android.app.ActivityManager;
import android.app.usage.UsageEvents;
import android.app.usage.UsageStatsManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.res.Resources;
import android.provider.Settings;
import android.util.Log;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
/**
* Read-only diagnostic: which apps were in front lately, which tasks are open now, and the
* system values a full-screen experiment for another app would touch. It changes nothing.
*
* The usage events and the full task list are open to the system user, which the car build
* runs as; the stable build sees only its own tasks and no events, and says so. Called on
* demand from the Logs tab, never in the background.
*/
final class ForegroundProbe {
private static final String TAG = "EVABRP.Foreground";
static final long WINDOW_MS = 30 * 60_000L;
private static final int MAX_LINES = 8;
private static final int MAX_TASKS = 4;
static final class Result {
/** Null when the platform would not give the events (not the system user). */
List<String> recent;
List<String> tasks = new ArrayList<>();
/** The {@code policy_control} global setting; null when unset. */
String policyControl;
/** The platform's status bar height in pixels; null when unknown. */
Integer statusBarPx;
}
private ForegroundProbe() {}
static Result read(Context context) {
Result r = new Result();
r.recent = recentForeground(context);
r.tasks = runningTasks(context);
try {
r.policyControl = Settings.Global.getString(context.getContentResolver(), "policy_control");
} catch (RuntimeException e) {
Log.w(TAG, "policy_control unreadable", e);
}
try {
Resources sys = Resources.getSystem();
int id = sys.getIdentifier("status_bar_height", "dimen", "android");
if (id != 0) r.statusBarPx = sys.getDimensionPixelSize(id);
} catch (RuntimeException e) {
Log.w(TAG, "status bar height unreadable", e);
}
return r;
}
// No PACKAGE_USAGE_STATS in the manifest: the usage service lets the system user through on
// its own, and any other caller gets an empty stream, reported as "not readable".
@SuppressLint("MissingPermission")
private static List<String> recentForeground(Context context) {
try {
UsageStatsManager usage = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);
if (usage == null) return null;
long now = System.currentTimeMillis();
UsageEvents events = usage.queryEvents(now - WINDOW_MS, now);
if (events == null) return null;
List<ForegroundHistory.Event> list = new ArrayList<>();
UsageEvents.Event e = new UsageEvents.Event();
boolean any = false;
while (events.hasNextEvent()) {
events.getNextEvent(e);
any = true;
if (e.getEventType() == UsageEvents.Event.MOVE_TO_FOREGROUND) {
list.add(new ForegroundHistory.Event(e.getTimeStamp(), e.getPackageName(), e.getClassName()));
}
}
// A caller without access gets an empty stream rather than an error; an empty
// stream for the system user would still contain our own app coming to the front.
if (!any) return null;
return ForegroundHistory.lines(list, MAX_LINES, TimeZone.getDefault());
} catch (RuntimeException e) {
Log.w(TAG, "usage events unreadable", e);
return null;
}
}
@SuppressWarnings("deprecation") // getRunningTasks: the system user still gets every task
private static List<String> runningTasks(Context context) {
List<String> out = new ArrayList<>();
try {
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) return out;
for (ActivityManager.RunningTaskInfo t : am.getRunningTasks(MAX_TASKS)) {
ComponentName top = t.topActivity;
if (top == null) continue;
String screen = ForegroundHistory.shortClass(top.getPackageName(), top.getClassName());
out.add(top.getPackageName() + (screen == null ? "" : " " + screen));
}
} catch (RuntimeException e) {
Log.w(TAG, "tasks unreadable", e);
}
return out;
}
}
@@ -203,6 +203,46 @@ public class MainActivity extends AppCompatActivity {
}
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
if (panes != null && panes[position] == logPane) refreshForeground();
}
private TextView foregroundText;
private final java.util.concurrent.ExecutorService diagReader =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> new Thread(r, "foreground-read"));
/** Logs tab diagnostic: reads which apps were in front, off the main thread. Changes nothing. */
private void refreshForeground() {
if (foregroundText == null) return;
try {
diagReader.execute(() -> {
ForegroundProbe.Result r = ForegroundProbe.read(getApplicationContext());
runOnUiThread(() -> { if (!isDestroyed()) foregroundText.setText(foregroundReport(r)); });
});
} catch (java.util.concurrent.RejectedExecutionException ignored) {
// Activity going away.
}
}
private String foregroundReport(ForegroundProbe.Result r) {
StringBuilder sb = new StringBuilder();
if (r.recent == null) {
sb.append(getString(R.string.foreground_unavailable)).append('\n');
} else if (r.recent.isEmpty()) {
sb.append(getString(R.string.foreground_none)).append('\n');
} else {
sb.append(getString(R.string.foreground_recent)).append('\n');
for (String line : r.recent) sb.append(line).append('\n');
}
if (!r.tasks.isEmpty()) {
sb.append('\n').append(getString(R.string.foreground_tasks)).append('\n');
for (String t : r.tasks) sb.append(t).append('\n');
}
sb.append('\n').append(getString(R.string.foreground_policy,
r.policyControl == null ? getString(R.string.foreground_policy_unset) : r.policyControl));
if (r.statusBarPx != null) {
sb.append('\n').append(getString(R.string.foreground_status_bar, r.statusBarPx));
}
return sb.toString();
}
/** Refreshes state + call log while the screen is visible. */
@@ -255,6 +295,8 @@ public class MainActivity extends AppCompatActivity {
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
foregroundText = logPane.findViewById(R.id.foreground_text);
logPane.findViewById(R.id.foreground_refresh_button).setOnClickListener(v -> refreshForeground());
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
@@ -343,6 +385,11 @@ public class MainActivity extends AppCompatActivity {
// Poll state and log only while the screen is up; onPause cancels it.
uiHandler.post(uiRefresh);
uiHandler.post(overviewRefresh);
// Back from another app (CarPlay) on the Logs tab: show where we were.
if (panes != null) {
ViewPager2 pager = findViewById(R.id.content);
if (panes[pager.getCurrentItem()] == logPane) refreshForeground();
}
}
@Override
@@ -395,6 +442,7 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
carReader.shutdownNow();
diagReader.shutdownNow();
super.onDestroy();
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<!-- Two columns: upload log | foreground apps (diagnostic). -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="16dp" 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" />
<TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_weight="1" 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="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/foreground_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/foreground_refresh_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/btn_refresh" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="?attr/evAccent" />
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/foreground_hint" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/foreground_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="14sp" android:lineSpacingExtra="2dp" android:textIsSelectable="true" />
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
+15 -5
View File
@@ -1,8 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" 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" xmlns:app="http://schemas.android.com/apk/res-auto" />
<TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" />
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" 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" />
<TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/foreground_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/foreground_refresh_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/btn_refresh" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="?attr/evAccent" />
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/foreground_hint" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/foreground_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="14sp" android:lineSpacingExtra="2dp" android:textIsSelectable="true" />
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
+9
View File
@@ -168,6 +168,15 @@
<string name="label_call_log">Recent uploads</string>
<string name="call_log_empty">No upload attempted yet.</string>
<string name="btn_flush_now">Upload now</string>
<string name="foreground_title">Foreground apps</string>
<string name="foreground_hint">For the CarPlay full-screen test: open CarPlay, come back here, read its name off the list. Reads only.</string>
<string name="foreground_recent">Last 30 minutes, newest first:</string>
<string name="foreground_none">No app came to the front in the last 30 minutes.</string>
<string name="foreground_unavailable">Not readable in this build: only the car build (system app) may see other apps.</string>
<string name="foreground_tasks">Open tasks now, top first:</string>
<string name="foreground_policy">policy_control: %1$s</string>
<string name="foreground_policy_unset">not set</string>
<string name="foreground_status_bar">Status bar height: %1$d px</string>
<string name="status_starting">Starting…</string>
<string name="status_running_no_upload">Running — waiting for first upload</string>
<string name="status_last_upload">Last upload: %1$s (%2$s)</string>
@@ -0,0 +1,69 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.TimeZone;
import org.junit.Test;
/** The Logs tab's foreground-app lines — pure JVM. */
public class ForegroundHistoryTest {
private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
private static ForegroundHistory.Event at(long seconds, String pkg, String cls) {
return new ForegroundHistory.Event(seconds * 1000, pkg, cls);
}
@Test
public void newestFirstWithTheActivityShortened() {
List<String> lines = ForegroundHistory.lines(Arrays.asList(
at(3600, "com.saic.launcher", "com.saic.launcher.Home"),
at(3700, "com.example.carplay", "com.example.carplay.ui.ProjectionActivity")),
10, UTC);
assertEquals(Arrays.asList(
"01:01:40 com.example.carplay .ui.ProjectionActivity",
"01:00:00 com.saic.launcher .Home"), lines);
}
@Test
public void repeatsOfTheSameScreenCollapse() {
List<String> lines = ForegroundHistory.lines(Arrays.asList(
at(1, "a.b", "a.b.Main"),
at(2, "a.b", "a.b.Main"),
at(3, "a.b", "a.b.Other"),
at(4, "c.d", null),
at(5, "c.d", null)), 10, UTC);
assertEquals(Arrays.asList(
"00:00:04 c.d",
"00:00:03 a.b .Other",
"00:00:01 a.b .Main"), lines);
}
@Test
public void limitKeepsTheNewest() {
List<String> lines = ForegroundHistory.lines(Arrays.asList(
at(1, "one", null), at(2, "two", null), at(3, "three", null)), 2, UTC);
assertEquals(Arrays.asList("00:00:03 three", "00:00:02 two"), lines);
}
@Test
public void emptyAndBlankEventsGiveNoLines() {
assertTrue(ForegroundHistory.lines(Collections.emptyList(), 10, UTC).isEmpty());
assertTrue(ForegroundHistory.lines(Arrays.asList(null, at(1, "", null), at(2, null, "x")), 10, UTC).isEmpty());
}
@Test
public void aClassOutsideItsPackageStaysWhole() {
assertEquals("com.other.Activity", ForegroundHistory.shortClass("a.b", "com.other.Activity"));
assertEquals(".Main", ForegroundHistory.shortClass("a.b", "a.b.Main"));
assertNull(ForegroundHistory.shortClass("a.b", null));
// "a.bc.Main" is not inside "a.b".
assertEquals("a.bc.Main", ForegroundHistory.shortClass("a.b", "a.bc.Main"));
}
}