diff --git a/AGENTS.md b/AGENTS.md index 2b4f967..2765fad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,9 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), `secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the index for changes that are not yours (the owner works in parallel), and scan the staged diff for secrets before every commit. Never print a token. -3. **Telemetry is read-only.** The app never writes a vehicle setting. +3. **Telemetry is read-only.** The app never writes a vehicle setting. The one system setting it + writes is `policy_control` (CarPlay full screen), only when the driver switches it on, and only + its own entry. 4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and shown as "—". Sending 0 for SOC tells ABRP the battery is empty. 5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state. @@ -66,7 +68,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | Consumption / Charging | period totals, daily log, charging sessions and curve | | 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 | -| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | +| Settings | full screen, voice key, CarPlay full screen (experiment), tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | | Logs | upload log | Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, diff --git a/README.md b/README.md index 0d0ab67..67f3924 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo | **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve | | **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 | -| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About | +| **Settings** | Full screen, voice key, CarPlay full screen (experiment), tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About | | **Logs** | Upload log | - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the diff --git a/SPECS.md b/SPECS.md index 135dadd..a6d35d4 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.2: Settings → "Full screen for CarPlay (experiment)". The Logs tab showed + CarPlay on the car as `com.allgo.carplay.service/.CarPlayActivity`, an ordinary activity. The + switch adds that package to `immersive.full` in Android 9's `policy_control` global setting + (system user only; elsewhere it is disabled) and, off, removes just that entry; it shows what the + setting holds. On the emulator: policy_control hid the status bar of another app and its content + took the space; the switch wrote and removed its entry and left an existing one alone. Not tried + on the car: whether SAIC's top bar is the status bar, and whether CarPlay's picture grows or + leaves a black strip until the phone reconnects. Tyre pressure and accent colour now sit side by + side in the wide Settings layout, so the page still fits. - 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 diff --git a/app/build.gradle b/app/build.gradle index ea8b2a4..d141da1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 100 - versionName "2.19.1" + versionCode 101 + versionName "2.19.2" // 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/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index e063276..047f272 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -1066,6 +1066,56 @@ public class MainActivity extends AppCompatActivity { // ---------- Upload page ---------- + /** The car's CarPlay app, as the Logs tab's foreground list showed it on SWI68. */ + private static final String CARPLAY_PACKAGE = "com.allgo.carplay.service"; + + /** + * Experiment: forces CarPlay into immersive mode through Android 9's policy_control setting. + * The switch shows what the setting holds, not a preference, and switching off removes only + * our entry. Writing it needs WRITE_SECURE_SETTINGS, which only the car build (system user) + * has; elsewhere the switch is disabled. + */ + private void bindCarPlayFullScreen() { + com.google.android.material.switchmaterial.SwitchMaterial sw = + settingsPane.findViewById(R.id.carplay_full_screen_switch); + boolean mayWrite = checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + String current = android.provider.Settings.Global.getString(getContentResolver(), PolicyControl.KEY); + sw.setOnCheckedChangeListener(null); + sw.setChecked(PolicyControl.hasFull(current, CARPLAY_PACKAGE)); + sw.setEnabled(mayWrite); + if (!mayWrite) { + ((TextView) settingsPane.findViewById(R.id.carplay_full_screen_hint)) + .setText(R.string.hint_carplay_full_screen_unavailable); + return; + } + sw.setOnCheckedChangeListener((btn, checked) -> diagReader.execute(() -> { + boolean ok; + try { + android.content.ContentResolver cr = getContentResolver(); + String before = android.provider.Settings.Global.getString(cr, PolicyControl.KEY); + String after = checked ? PolicyControl.withFull(before, CARPLAY_PACKAGE) + : PolicyControl.withoutFull(before, CARPLAY_PACKAGE); + android.provider.Settings.Global.putString(cr, PolicyControl.KEY, after); + String now = android.provider.Settings.Global.getString(cr, PolicyControl.KEY); + ok = PolicyControl.hasFull(now, CARPLAY_PACKAGE) == checked; + android.util.Log.i("EVABRP.Settings", "policy_control " + (checked ? "on" : "off") + ": " + now); + } catch (RuntimeException e) { + android.util.Log.w("EVABRP.Settings", "policy_control not written", e); + ok = false; + } + final boolean written = ok; + runOnUiThread(() -> { + if (isDestroyed()) return; + if (!written) { + Toast.makeText(this, R.string.carplay_full_screen_failed, Toast.LENGTH_LONG).show(); + bindCarPlayFullScreen(); + } + refreshForeground(); + }); + })); + } + private void bindServicePane() { voiceKeySwitch = settingsPane.findViewById(R.id.voice_key_switch); voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs)); @@ -1079,6 +1129,7 @@ public class MainActivity extends AppCompatActivity { prefs.edit().putBoolean(UploadSettings.KEY_FULL_SCREEN, checked).apply(); applyFullScreen(); }); + bindCarPlayFullScreen(); liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch); intervalChoices = servicePane.findViewById(R.id.interval_choices); boostSwitch = servicePane.findViewById(R.id.boost_switch); diff --git a/app/src/main/java/com/evsuite/abrp/PolicyControl.java b/app/src/main/java/com/evsuite/abrp/PolicyControl.java new file mode 100644 index 0000000..7ab69b1 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PolicyControl.java @@ -0,0 +1,79 @@ +package com.evsuite.abrp; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The value of Android 9's {@code policy_control} global setting, which forces other apps into + * immersive mode: {@code immersive.full=pkg1,pkg2:immersive.status=pkg3}. This adds or removes + * one package in {@code immersive.full} and leaves every other entry as it was, so switching + * our experiment off restores what was there before. Pure Java, so it is unit-tested. + */ +final class PolicyControl { + + static final String KEY = "policy_control"; + static final String FULL = "immersive.full"; + + private PolicyControl() {} + + /** Whether {@code pkg} is named in {@code immersive.full}. */ + static boolean hasFull(String value, String pkg) { + List list = parse(value).get(FULL); + return list != null && list.contains(pkg); + } + + /** {@code value} with {@code pkg} added to {@code immersive.full}. */ + static String withFull(String value, String pkg) { + Map> map = parse(value); + List list = map.get(FULL); + if (list == null) { + list = new ArrayList<>(); + map.put(FULL, list); + } + if (!list.contains(pkg)) list.add(pkg); + return format(map); + } + + /** {@code value} without {@code pkg} in {@code immersive.full}; null when nothing is left. */ + static String withoutFull(String value, String pkg) { + Map> map = parse(value); + List list = map.get(FULL); + if (list != null) { + list.remove(pkg); + if (list.isEmpty()) map.remove(FULL); + } + return format(map); + } + + private static Map> parse(String value) { + Map> map = new LinkedHashMap<>(); + if (value == null) return map; + for (String part : value.split(":")) { + int eq = part.indexOf('='); + if (eq <= 0) continue; + String name = part.substring(0, eq).trim(); + List list = map.get(name); + if (list == null) { + list = new ArrayList<>(); + map.put(name, list); + } + for (String p : part.substring(eq + 1).split(",")) { + String t = p.trim(); + if (!t.isEmpty() && !list.contains(t)) list.add(t); + } + } + return map; + } + + private static String format(Map> map) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry> e : map.entrySet()) { + if (e.getValue().isEmpty()) continue; + if (sb.length() > 0) sb.append(':'); + sb.append(e.getKey()).append('=').append(String.join(",", e.getValue())); + } + return sb.length() == 0 ? null : sb.toString(); + } +} diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 095f31a..6028d23 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -10,14 +10,22 @@ - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 437f3e5..8432da2 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -7,6 +7,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ed6753..ab7d81b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,7 +28,7 @@ Power, 5-min average %1$.1f kW needs a minute of data - Tyre pressure unit + Tyre pressure Accent colour %1$.1f kWh net, after recuperation @@ -114,6 +114,10 @@ Full screen Hides the car\'s top and bottom bars while this app is open. Swipe in from the top or bottom edge to show them briefly, e.g. for the climate controls. + Full screen for CarPlay (experiment) + Hides the car\'s bars over CarPlay too, from its next start. A black strip? Reconnect the phone. Off restores the setting. + Needs the car build: only a system app may change this. + Could not change the CarPlay full-screen setting Double-press the voice key to open this app Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses. Send live data diff --git a/app/src/test/java/com/evsuite/abrp/PolicyControlTest.java b/app/src/test/java/com/evsuite/abrp/PolicyControlTest.java new file mode 100644 index 0000000..aa216b5 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PolicyControlTest.java @@ -0,0 +1,50 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** The policy_control value the CarPlay full-screen switch writes — pure JVM. */ +public class PolicyControlTest { + + private static final String CP = "com.allgo.carplay.service"; + + @Test + public void unsetGainsOnlyOurEntryAndLosesItAgain() { + String on = PolicyControl.withFull(null, CP); + assertEquals("immersive.full=" + CP, on); + assertTrue(PolicyControl.hasFull(on, CP)); + assertNull(PolicyControl.withoutFull(on, CP)); + } + + @Test + public void otherEntriesSurviveOnAndOff() { + String before = "immersive.status=com.a:immersive.full=com.b"; + String on = PolicyControl.withFull(before, CP); + assertEquals("immersive.status=com.a:immersive.full=com.b," + CP, on); + assertEquals(before, PolicyControl.withoutFull(on, CP)); + } + + @Test + public void addingTwiceAddsOnce() { + String once = PolicyControl.withFull(null, CP); + assertEquals(once, PolicyControl.withFull(once, CP)); + } + + @Test + public void onlyAnExactPackageCounts() { + assertFalse(PolicyControl.hasFull("immersive.full=" + CP + ".other", CP)); + assertFalse(PolicyControl.hasFull("immersive.status=" + CP, CP)); + assertFalse(PolicyControl.hasFull(null, CP)); + assertFalse(PolicyControl.hasFull("", CP)); + } + + @Test + public void removingWhatIsNotThereKeepsTheRest() { + assertEquals("immersive.status=com.a", PolicyControl.withoutFull("immersive.status=com.a", CP)); + assertNull(PolicyControl.withoutFull(null, CP)); + } +}