App 2.19.2: Full screen for CarPlay (experiment)
Adds com.allgo.carplay.service to immersive.full in Android 9's policy_control setting when switched on, removes only that entry when off. Car build (system user) only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> 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<String, List<String>> map = parse(value);
|
||||
List<String> 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<String, List<String>> map = parse(value);
|
||||
List<String> list = map.get(FULL);
|
||||
if (list != null) {
|
||||
list.remove(pkg);
|
||||
if (list.isEmpty()) map.remove(FULL);
|
||||
}
|
||||
return format(map);
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> parse(String value) {
|
||||
Map<String, List<String>> 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<String> 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<String, List<String>> map) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, List<String>> 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();
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,22 @@
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/carplay_full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_carplay_full_screen" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:id="@+id/carplay_full_screen_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_carplay_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="2" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="3" android:layout_height="wrap_content" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/carplay_full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_carplay_full_screen" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:id="@+id/carplay_full_screen_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_carplay_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="vehicle_row_power_average">Power, 5-min average</string>
|
||||
<string name="power_kw">%1$.1f kW</string>
|
||||
<string name="power_waiting">needs a minute of data</string>
|
||||
<string name="label_tire_unit">Tyre pressure unit</string>
|
||||
<string name="label_tire_unit">Tyre pressure</string>
|
||||
<string name="label_accent">Accent colour</string>
|
||||
<string name="energy_kwh">%1$.1f kWh</string>
|
||||
<string name="energy_trip_note">net, after recuperation</string>
|
||||
@@ -114,6 +114,10 @@
|
||||
|
||||
<string name="label_full_screen">Full screen</string>
|
||||
<string name="hint_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.</string>
|
||||
<string name="label_carplay_full_screen">Full screen for CarPlay (experiment)</string>
|
||||
<string name="hint_carplay_full_screen">Hides the car\'s bars over CarPlay too, from its next start. A black strip? Reconnect the phone. Off restores the setting.</string>
|
||||
<string name="hint_carplay_full_screen_unavailable">Needs the car build: only a system app may change this.</string>
|
||||
<string name="carplay_full_screen_failed">Could not change the CarPlay full-screen setting</string>
|
||||
<string name="label_voice_key">Double-press the voice key to open this app</string>
|
||||
<string name="hint_voice_key">Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses.</string>
|
||||
<string name="label_abrp_upload_enabled">Send live data</string>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user