diff --git a/SPECS.md b/SPECS.md index 1424d39..135dadd 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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=`, 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` diff --git a/app/build.gradle b/app/build.gradle index 73348e8..ea8b2a4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/evsuite/abrp/ForegroundHistory.java b/app/src/main/java/com/evsuite/abrp/ForegroundHistory.java new file mode 100644 index 0000000..1deb666 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ForegroundHistory.java @@ -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 lines(List oldestFirst, int limit, TimeZone zone) { + List 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 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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ForegroundProbe.java b/app/src/main/java/com/evsuite/abrp/ForegroundProbe.java new file mode 100644 index 0000000..03ae645 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ForegroundProbe.java @@ -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 recent; + List 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 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 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 runningTasks(Context context) { + List 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; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 4034672..e063276 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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(); } diff --git a/app/src/main/res/layout-w1000dp/pane_log.xml b/app/src/main/res/layout-w1000dp/pane_log.xml new file mode 100644 index 0000000..2050239 --- /dev/null +++ b/app/src/main/res/layout-w1000dp/pane_log.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 7bfc7fc..863c052 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -1,8 +1,18 @@ - - - - - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ea54dc..0ed6753 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,6 +168,15 @@ Recent uploads No upload attempted yet. Upload now + Foreground apps + For the CarPlay full-screen test: open CarPlay, come back here, read its name off the list. Reads only. + Last 30 minutes, newest first: + No app came to the front in the last 30 minutes. + Not readable in this build: only the car build (system app) may see other apps. + Open tasks now, top first: + policy_control: %1$s + not set + Status bar height: %1$d px Starting… Running — waiting for first upload Last upload: %1$s (%2$s) diff --git a/app/src/test/java/com/evsuite/abrp/ForegroundHistoryTest.java b/app/src/test/java/com/evsuite/abrp/ForegroundHistoryTest.java new file mode 100644 index 0000000..0fb24cd --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ForegroundHistoryTest.java @@ -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 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 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 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")); + } +}