One carousel page, built at runtime from the driver's own list. The fixed system page
(page 2) and the fixed vehicle page (page 3) become a single page 2 whose cards the driver
adds, replaces and removes — exactly the gestures the favourite apps already use on the home
page. MetricsFragment replaces SystemInfoFragment and VehicleInfoFragment, which are
deleted.
This supersedes the layout half of CR-010. Its boundary half —
the launcher reads the car only through EVHardware's typed, nullable telemetry API, owns no
property id, no vendor transaction and no setter, and writes nothing — is unchanged and still
enforced by VehicleBoundaryTest.
CR-010 rejected merging because seven fixed cards on a 1920 dp panel leaves ~253 dp each, below what a 28 sp label over a 32 sp value can carry. That arithmetic assumed one row.
The home page had already solved the same problem for favourites: past four tiles it wraps to a second and then a third row. Reusing that banding here keeps every card at least a quarter of the panel wide:
1 row : up to 4 cards ≈ 460 dp each
2 rows : up to 8 cards ≈ 460 dp × 2 rows
3 rows : up to 12 cards ≈ 460 dp × 3 rows
PreferencesManager.MAX_METRICS is 12 for the same reason MAX_FAVORITES is: a 4×3 grid is
the last one whose cards are still readable at a glance from the driver's seat.
The seven cards the two old pages showed, in their old order: device, memory, storage, network, charge, range, charging. Merging two pages must not silently take a value away from a driver who never opens the picker, so the default is the union, not a new opinion.
An explicitly emptied page stays empty — the stored value is an empty string, which is distinct from "never customised" (unset).
Forty-three entries in two sections. The enum constant name in Metric is the persisted key,
so a label change belongs in strings.xml and a rename would drop the card from the page of
every driver who chose it.
Head unit (18). Device, chipset, CPU load, memory, storage, network, IP address, data
today / this month / since boot, date, time, weather, uptime, Android version, kernel, screen,
launcher version. All permission-free /proc, /sys and framework reads except the two data
windows and the weather (below). CPU load is a ratio between two ticks, so the first refresh
after the page opens primes the counters and shows the dash. Date and time follow the system's
own locale and 12/24-hour setting rather than a format this app picks.
Vehicle (25). Every field of EVHardware's EnergySnapshot: charge, range, charging,
charge port, speed, battery power / energy / capacity / temperature, outside and cabin
temperature, odometer, gear, the eight climate signals and the four tyre pressures.
The first cut used an AlertDialog.setItems list. Forty entries in a dialog is a scrolling
column of small rows on a panel the driver reaches across — the wrong component for the
number of choices, and the smallest touch target in the app.
MetricPickerActivity is a full screen with the same header + grid shape as
AppDrawerActivity, so the launcher's two pickers behave alike: back button, title, and a
grid of 128 dp cells under a Head unit / Vehicle section header. Like the app picker
it writes the choice itself and finishes; MetricsFragment rebuilds from preferences in
onResume, so there is one owner of the write and no result to plumb back. Metrics already on
the page are left out, except the one in the slot being replaced.
The long-press menu (replace / remove) stays a dialog: two items is what a dialog is for.
The data-today and data-this-month cards go through EVHardware.DataUsage, which reads
Android's own NetworkStatsManager counters — the numbers the Settings screen shows. That
needs android.permission.PACKAGE_USAGE_STATS, which is added to the manifest by this
change. It is read-only, device-wide (no per-app breakdown is queried), and it is not a car
permission, so the vehicle boundary is untouched. The since-boot card uses TrafficStats and
needs nothing; the two windowed cards fall back to — rather than to zero when the counters
do not answer.
DataUsage is added to VehicleBoundaryTest's EVHardware allowlist for the same reason: it
names no vehicle API at all, and lives in the library only because every app in the suite has
to read data usage the same awkward way (the head unit's modem presents as Ethernet, so the
public querySummaryForDevice(int, …) template answers zero).
SaicWeather answers for a position, so a weather card needs one. That is a real cost in
an app whose selling point is that its stats are permission-free, and it is confined as
tightly as the feature allows:
ACCESS_FINE_LOCATIONandACCESS_COARSE_LOCATIONare declared, but requested at runtime only the first time a weather card is displayed — never at launch. A driver who does not add the card is never asked, and a driver who declines is not asked again on every swipe back.- Both grades are declared because since API 31 the user may grant the approximate one when the precise one was asked for, and a city's weather does not need the precise one.
- The position comes from a subscription held only while the page is visible and only
while a weather card is on it, at 60 s / 1 km. It is dropped in
onPause. A subscription rather thangetLastKnownLocationalone because on a head unit with no other GPS client that cache is empty and stays empty — the same finding that made EVTasker's vehicle service hold one. - The query blocks up to two seconds waiting on the service's callback binder, so it runs on a single background thread and is re-read at most every ten minutes. Two seconds on the tick would be a frozen home screen.
- Three silences, and the card names which: needs the location permission, waiting for a position, weather unavailable. None of them is a zero degrees.
SaicWeather joins VehicleBoundaryTest's allowlist: it is an app service on the head unit's
map stack with one query and no setter, and it reaches no car API — unlike SaicClimate and
SaicCharging, which stay forbidden. Forecasts are not offered: the service declares no
transaction that answers one.
The first cut of this page offered every field of EnergySnapshot, including the standard
AAOS properties this app has no permission for, on the argument that a dash with unavailable
on this car was an honest answer and made the gap legible.
On the vehicle it was not honest, it was noise. A driver picking from the list has no way to know which entries can never answer, and EVTasker's diagnostic — the reference for what this head unit actually gives up — shows the same signals unreadable there for the same reason. So the catalogue now lists only what can answer, and the rest are removed:
| Removed metric | Read path | Why it can never answer here |
|---|---|---|
| Speed | PERF_VEHICLE_SPEED |
CAR_SPEED, not declared |
| Cabin temperature | HVAC_TEMPERATURE_CURRENT |
CONTROL_CAR_CLIMATE, not declared, and VehicleBoundaryTest forbids any CONTROL_ permission here |
| Odometer | PERF_ODOMETER |
CAR_MILEAGE, privileged. EVHardware grew a SaicNav fallback for it after the version this app pins, and nothing binds that service here |
| Charge port | EV_CHARGE_PORT_CONNECTED |
CAR_ENERGY_PORTS, held by no app in the suite |
| Tyre pressures ×4 | TIRE_PRESSURE |
CAR_TIRES, privileged |
| Battery capacity | INFO_EV_BATTERY_CAPACITY |
CAR_INFO, not declared |
| Battery power, energy, temperature | EV_*, gated on CAR_ENERGY |
Declared, but CAR_ENERGY is dangerous: a manifest entry alone does not grant it (see EVChargePilot's VehiclePermissions) and this app requests no car permission at runtime |
What is left is what the vendor AIDL services answer — state of charge, range, charging
status, outside temperature, the whole climate block — plus the gear position, which
EVHardware.isVehicleInPark reads through the vendor condition manager rather than
CarPropertyManager. None of those needs a car permission, which is exactly why they work.
The charging card lost its port-flag caption for the same reason: that flag was never once non-null on this car.
The car permissions are unchanged by this correction: still CAR_ENERGY and
CAR_VENDOR_EXTENSION. Re-adding any removed metric is a separate change and needs more
than a manifest line — a boundary review, an entry in VehicleBoundaryTest's manifest
allowlist, a runtime request for the dangerous ones, and on-vehicle evidence that the
property answers at all (CP-003). A permission that is merely declared buys nothing.
Two cadences on one ticker, unchanged from the pages they replace: the system cards tick every
3 s, the vehicle snapshot is re-read at most every 5 s — state of charge and range move over
minutes, and that read reaches the vehicle layer rather than /proc.
The reader is built on the first refresh that actually needs it, so a page holding only
system cards binds no vehicle layer at all — the same property the standalone vehicle page
had, now conditioned on the card list instead of on which page was swiped to. The ticker runs
only between onResume and onPause, and EVHardware keeps owning the binding and its
reconnection watchdog, so nothing here tears a bound service down underneath it.
- The carousel is two pages:
HomePagerAdapter.PAGE_COUNTis 2, andactivity_main.xmlhas two pagination bars. PreferencesManagernow persists two ordered, hole-free lists under one prefs file; the favourites API and its legacy three-slot migration are untouched.ConsumptionCalculator.instantaneousis deliberately not offered as a card: it derives kWh/100 km from battery power and speed, and speed needsCAR_SPEED, so the card could never show a number. Offering it would mean widening the EVHardware allowlist for a permanent dash — the same reason the speed card itself is now gone.- A stored key this build does not know (a downgrade, or a metric later dropped) is skipped rather than shown as an empty card.