Compare commits

...
80 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 f54e684ae1 Merge english-translation: Nils' MG4 (app 2.18.0, server 2.6.0)
Turns the FatihsMG4 fork into Nils' MG4: an English Android Automotive
app for the MG4 head unit, a Go car server with InfluxDB 2 and Grafana,
Kubernetes manifests, and updates over the air or from a USB stick.
AGENTS.md is the overview, SPECS.md the journal of every change.

App (com.stinnesbeck.nilsmg4, car build .unstable)
- English throughout; all UI text in strings.xml. Renamed with a new
  package ID, NilsMG4-*.apk names, three-part versions; About links to
  git.stinnesbeck.com/nils/mg4 and keeps the upstream credits.
- build-car (Nix flake) signs the car build with the AOSP platform key
  the head unit trusts; nix run .#car-emulator gives a matching Android
  9 emulator.
- Overview, fitting the car's 1778x720 window without scrolling:
  battery, range, range/usage/energy since start, 5-minute averages of
  range, usage, power and speed, vehicle info, tyre pressures in
  bar/kPa/psi. Read every 0.5 s on a background thread; values
  right-aligned with equal-width digits; recuperation as negative usage
  and an infinite range.
- Head unit: full screen (kept with drop-downs and dialogs), voice-key
  double press opens the app, selectable accent colour after the MG4
  paints (Dynamic Red by default), no scroll bars.
- Tabs: Overview, Consumption, Charging, Live data (on/off, ABRP
  credentials, frequency), Server (URL, QR pairing, token, read-only
  installation ID, config import), Settings (display, battery,
  charging price, update check, install from USB, About), Logs.
- Live data to the car server and straight to ABRP; only values within
  the server's ranges; the queue survives restarts; GPS pauses while
  parked and idle.
- Settings can be typed on a phone (QR code, form on the server) or
  imported from a config file, which is deleted afterwards.
- Security: credentials in encrypted storage (moved back after a
  keystore failure), installation ID derived from the head unit.
- Updates: from this repository's pre-releases through the car server,
  or from a USB stick; SHA-256, package and signature checked; the
  installer session ran silently on the emulator.
- Removed: Wi-Fi auto-connect (and its permissions), the floating
  button, upstream's diagnostics.

Server (server/, Go 1.26, replaces upstream's Next.js app)
- Telemetry, consumption chunks and charging sessions in InfluxDB 2;
  idempotent writes; a bad value or time drops only itself.
- Pairing by QR code approved on a phone; paired tokens bound to their
  installation; lockouts and limits per client address.
- Settings from a phone (in memory only, at most 10 minutes, handed out
  once); app releases relayed from the private forge with a read-only
  FORGE_TOKEN. No ABRP forwarding: the car sends to ABRP itself.

Infrastructure (k8s/, plain manifests, applied by hand)
- InfluxDB 2.7.12, the server pinned by digest, Grafana 13.2.3 with
  provisioned Live, Consumption and Charging dashboards (euro),
  Traefik traefik-internal routes.
- Grafana's read-only InfluxDB token is made by an init container at
  each start; the Grafana process never sees the admin token.
- Filled-in secrets only in the gitignored k8s/secrets/secrets.yaml.

Licence
- MIT throughout, like Leon Kernan's ABRP_Uploader (MIT since
  2026-07-29) and the forks in between; LICENSE names every copyright
  holder. EVHardware/ is PolyForm Noncommercial, so the built APK is
  non-commercial. EVProfile's MG4Control lineage is from MG4Control's
  MIT period (checked).

Deploying
1. Fill in k8s/secrets/secrets.yaml from the template (InfluxDB admin,
   ingest token, approval password, read-only FORGE_TOKEN, Grafana
   admin password).
2. kubectl apply -f k8s/namespace.yaml, then
   kubectl apply -f k8s/secrets/secrets.yaml, then kubectl apply -k k8s/
3. Car: install the car build once by hand; after that build-car, then
   publish-car (or copy the APK and .sha256 to a USB stick), then the
   buttons on the Settings tab.

Testing
- App unit tests (stable and car build) and lint pass; server tests
  pass and govulncheck is clean; manifests validate offline.
- End to end in podman: server with InfluxDB (telemetry, pairing, phone
  settings), Grafana with the init-container token; release relay
  against a fake Gitea.
- Emulator (Android 9, 1920x720 and 1778x640): layouts, tabs, accent
  colours, config import, queue restore, USB install.
- On the car: pairing, live values, voice key, full screen, screen
  size. Not yet on the car: direct ABRP sending, Enter on phone, accent
  colours, 0.5 s refresh, over-the-air and USB updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:22:13 +02:00
nilsandClaude Opus 5.5 253c768781 AGENTS.md: an overview for agents; car-emulator in the flake
AGENTS.md still described upstream's EVABRPUploader (GitHub updates,
the old architecture and tabs). It is now the entry point for agents:
what the project is (app, Go server, k8s, EVHardware), the rules
(nothing touches the cluster, secrets, read-only telemetry, null is not
zero, fail closed, honesty), the facts confirmed on the car, the app's
structure and UI conventions, how to build, test, publish and install,
and which document holds what. SPECS.md stays the journal.

The Android 9 test emulator, so far a file under the ignored build/,
is now reproducible: nix run .#car-emulator creates an AVD at
1920x720/160 dpi with the AOSP image that shares the car's platform key
and starts it on port 5560. Checked from scratch: the car build
installs as the system user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:12:41 +02:00
nilsandClaude Opus 5.5 69d284f81b App 2.18.0: install from a USB stick
Settings -> App -> "Install from USB stick" (car build only). It looks
in the top folder of every mounted volume (found from the app's own
folder on it, since the head unit has no file picker) for
NilsMG4-unstable-<version>.apk, takes the newest one newer than the
installed version, checks it against its .sha256 sidecar and the
package name, and installs it with the same PackageInstaller session
as the over-the-air update. The stable build gets a stub and no button.

Tested on the emulator: 2.18.0 installed a 2.18.1 from the storage's
top folder without a confirmation, as the system user, so the OTA
install step should not need one either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:59:45 +02:00
nilsandClaude Opus 5.5 0fd024cc2e SPECS: done-log entry for app 2.17.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:53:21 +02:00
nilsandClaude Opus 5.5 5a82fe3dc1 App 2.17.5: Server tab fits; installation ID is read-only
- The "Your private connection" card is gone; its text was out of date
  (the ABRP credentials are no longer on the server).
- Import from file moved next to Save and Test, in one row.
- The installation ID is shown as text instead of an editable field:
  it is derived from the head unit, and changing it by hand only risked
  splitting the history. A config file can still carry an older one
  over, as before.
- The page fits the car's 1778x640 window without scrolling (checked
  on the emulator), and the subtitle no longer says the server passes
  data on to ABRP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:53:05 +02:00
nilsandClaude Opus 5.5 5ead905e5f App 2.17.4: the panel is called "Vehicle info"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:37:16 +02:00
nilsandClaude Opus 5.5 224840bde2 App 2.17.3: "Odometer and climate" instead of "Energy and temperature"
The Overview panel's title came from upstream, where it also showed
pack energy and temperatures. The MG4 publishes none of those, so the
panel only holds the odometer and the climate setpoint; its title now
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:35:43 +02:00
nilsandClaude Opus 5.5 d4bb502026 App 2.17.2: Wi-Fi auto-connect removed
Upstream kept the head unit on the driver's phone hotspot by re-joining
a saved network every few minutes. This car's head unit has its own
connection and the Wi-Fi is not used, so the feature goes entirely:
WifiAutoConnect, its call on the service tick, the preference key, and
the ACCESS_WIFI_STATE / CHANGE_WIFI_STATE permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:34:05 +02:00
nilsandClaude Opus 5.5 851a4a34d4 App 2.17.1: tidier Settings and Logs
- Settings: the Wi-Fi and saved-networks cards are gone, and the rest
  sits in three columns (display and steering wheel | battery |
  charging price and app) instead of one tall column. The background
  Wi-Fi auto-connect stays on, without a switch.
- Logs: only the upload log. The steering-key reports, the car-colour
  probe and the screen line answered their questions and are removed,
  with their code (CarColorProbe, the key-report log) and strings.
- README lists the current tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:32:01 +02:00
nilsandClaude Opus 5.5 8d3eb80f66 App 2.17.0: speed average, compact top bar, About in Settings
- "Speed, 5-min average" card under Speed: time-weighted mean speed,
  standing included, from the same samples as the other averages.
- The top bar is one compact line (title and subtitle side by side).
  Version, update check and About moved from it to an App card on the
  Settings tab.
- The Overview no longer scrolls in the car's window: checked on the
  emulator at 1778x640, the layout height the car reports, after
  trimming the page's padding and the tyre card's height.
- The capacity hint says "Overview" instead of "Vehicle tab".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:26:02 +02:00
nilsandClaude Opus 5.5 79bcfa05f6 App 2.16.4: drop-downs that keep full screen
Back to drop-downs for upload frequency, tyre pressure unit and accent
colour, as Material exposed dropdown menus instead of Spinners. A
Spinner's list is a focusable window of its own without the
full-screen flags, so the car showed its bars while it was open; an
exposed dropdown's list leaves the focus with the activity, so the app
stays full screen. Styled like the other input fields, with full-height
rows. Replaces the buttons of 2.16.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:11:25 +02:00
nilsandClaude Opus 5.5 f614ab08be App 2.16.3: full screen stays with drop-downs and dialogs
Opening a drop-down or a dialog brought the car's bars back until it
closed: each is a window of its own, and the system shows the bars when
a window without the full-screen flags takes focus.

- The drop-downs (upload frequency, tyre pressure unit, accent colour)
  are now single-choice buttons on the page: no extra window, one tap
  instead of two, full touch targets, the choice in the accent colour.
- Dialogs (About, update, pairing, phone settings, trip reset, date
  picker) are shown unfocusable, given the activity's full-screen
  flags, and only then allowed focus. None of them takes typing, which
  an unfocusable window would block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:05:26 +02:00
nilsandClaude Opus 5.5 98ec3f3fcf Docs: the car's screen is 1920x720 at 160 dpi; no colour property
Read on the car with 2.16.2: panel 1920x720 px at 160 dpi, app window
1778x720 px (the car's own sidebar takes the rest), so EVProfile's
1280x480 (1920x720 at 240 dpi, in dp) does not apply here. The colour
probe found only ro.build.mtk2712.vehicle_type = AS28: the paint colour
is not among the system properties. FIRMWARE.md, mise.toml and
SPECS.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:56:57 +02:00
nilsandClaude Opus 5.5 bfa33d51bd App 2.16.2: show the car's screen size; display figures documented
The MG4's display is given as 1920x720 (this project), 1280x480
(EVProfile's README) and a 1778x720 app window (upstream). 1280x480 is
exactly 1920x720 px at 240 dpi expressed in dp, so the figures may
describe one panel at different densities, which this platform lets be
changed per app.

The Logs tab's new SCREEN line shows the panel's pixels and density and
the app window in px and dp, to settle it from the car. FIRMWARE.md
lists the three figures and what the emulator shows at 240 dpi;
mise.toml, README and SPECS.md no longer state 160 dpi as fact for the
car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:50:24 +02:00
nilsandClaude Opus 5.5 c2c0640672 LICENSE.md: EVProfile's upstream licence is settled
EVHardware's notes call the licence of EVProfile's upstream unresolved.
Checked: malys/EVProfile is MIT, forked from SliDeeN/MG4Control and last
merged from it on 2026-08-02. MG4Control is MIT up to 2.6.5
(2026-08-05) and GPL v3 from 2.6.6 (2026-09-02), with the earlier
versions staying MIT, so the code EVProfile took is from the MIT
period. Recorded with links; not an audit of every EVHardware line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:41:12 +02:00
nilsandClaude Opus 5.5 a293a0fa94 Licence: the original is MIT; EVHardware is noncommercial
Leon Kernan's ABRP_Uploader has carried the MIT Licence since
2026-07-29 (lkernan/ABRP_Uploader commit 75d8f2bc), so the inherited
"no licence upstream, status unresolved" notes no longer hold.

- LICENSE: MIT, now naming every copyright holder in the fork chain
  (Leon Kernan, EVABRPUploader contributors, FatihsMG4 contributors,
  Nils Stinnesbeck).
- LICENSE.md: rewritten. The repository is MIT except EVHardware/,
  which is PolyForm Noncommercial 1.0.0; the app links it into the APK,
  so the built app is non-commercial. EVHardware's own caveat about
  EVProfile stays with it.
- README, AGENTS.md, CONTRIBUTING.md and SPECS.md say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:38:21 +02:00
nilsandClaude Opus 5.5 c71fa4504e App 2.16.1: Overview values every half second
The Overview read the car every 2 s, on the main thread. It now reads
every 0.5 s on a background thread (one reader; a read still running
makes the next tick skip rather than queue) and shows the values on the
main thread. The consumption and charging trackers stay on the 2-second
tick with the newest reading: they integrate over time, and the
charging tracker writes its session to storage on every sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:32:50 +02:00
nilsandClaude Opus 5.5 9226616261 Grafana's read-only InfluxDB token is made automatically
The read-only token for Grafana needed a manual influx command and a
Secret. InfluxDB generates every token but the admin one and ignores a
chosen value (tested on 2.7.12), so it cannot be preset in a Secret.

Instead an init container in the Grafana pod creates it at every start
with the admin token, deletes the one from the previous start, and
writes only the read-only token to a memory-backed volume; the
provisioned datasource reads it with $__file{/run/influx/token}. The
Grafana process never sees the admin token. The grafana-influx Secret
is gone from the template and the README.

Tested in podman: InfluxDB 2.7.12, the script run twice as the Grafana
user (the old token is replaced, not piled up), and Grafana 13.2.3
reporting the datasource healthy with the file-based token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:05:58 +02:00
nilsandClaude Opus 5.5 e5549dddb0 App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 18:08:08 +02:00
nilsandClaude Opus 5.5 f88249427f App 2.15.0: queued live data survives restarts
Unsent live samples were kept in memory only, so a reboot or a killed
process during a dead spot lost them. The queue is now saved to
files/telemetry_queue.json with AtomicFile, at most every 5 minutes and
only after a change (the flash is not written every 15 s), and at once
when the service stops; it is restored, oldest first and within the
2000-sample cap, when the service starts. A damaged file restores
nothing rather than failing. Tested on the emulator across a reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:56:25 +02:00
nilsandClaude Opus 5.5 f63f48131e App 2.14.4: all UI text in strings.xml
The Charging, Consumption and Settings labels, toasts, the trip-reset
dialog, the charging and Wi-Fi states, the daily summary and the "—"
placeholder move from layouts and code into strings.xml; lint now
reports no hard-coded text. Upload-log entries stay in code, as log
text. No visible change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:50:55 +02:00
nilsandClaude Opus 5.5 29064cf8db Translate the remaining French and Turkish
mise.toml, the comments in app/build.gradle (corrected where they no
longer described the build: the car build is signed by build-car, and
the updater reads the version from build-car's file name), OTA.md's
message table (now the updater's current English messages), the Turkish
messages quoted in CHANGELOG.md and OTA.md (glossed in English), two
test assertion messages, an icon comment, and the ignition-state comment
in EVHardware.kt. The vendored EVHardware library still has about 120
half-French comment lines; noted in SPECS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:45:56 +02:00
nilsandClaude Opus 5.5 5e2e354bdd About dialog points to our repository; SPECS status refreshed
The About dialog's link and QR code open
https://git.stinnesbeck.com/nils/mg4 instead of Fatih's GitHub
repository (the credits to upstream stay). SPECS.md: the Status section
describes the current versions and what has and has not been tried on
the car; the architecture notes list the current tabs and data paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:42:54 +02:00
nilsandClaude Opus 5.5 727da51a3c App 2.14.2: "Range, 5-min average"
Same form as "Usage, 5-min average" and "Power, 5-min average".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:25:35 +02:00
nilsandClaude Opus 5.5 010a0cd50d App 2.14.1: right-aligned values with equal-width digits
The Overview tiles' values (and the notes under them) are right-aligned
and use tabular figures, so a value going from 9 to 10 to 100 grows to
the left instead of shifting the whole text, and digits no longer
change width as they tick. The labels stay left; tyre pressures stay
centred but use tabular figures too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:22:22 +02:00
nilsandClaude Opus 5.5 9b5fcc0734 App 2.14.0: accent colour after the MG4 paints; car colour probe
The accent is selectable in Settings → Display: Dynamic Red (default),
Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument
Silver, Arctic White and Black Pearl. White, silver and black cannot be
an accent on the black screen as they are, so they become light greys
and a pearl blue-grey.

The accent palette is now theme attributes (attrs_accent.xml) with one
overlay per paint (accents.xml), applied before the activity inflates;
layouts, colour lists, drawables and the views that draw in code use
them, including the red tints that were hard-coded. A change recreates
the activity on the same page. Android 9 crashes on gradients with
theme colours, so the battery card is built from layers.

Logs → "Look for the car colour" lists the head unit's system
properties that mention colour, paint or car model, to find out whether
the paint colour can be read from the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:52:57 +02:00
nilsandClaude Opus 5.5 40906ec4a7 App 2.13.0: tyre pressures in bar; 5-minute average power
Tyre pressures show in bar by default; Settings → Display offers kPa
and psi as well. Display only: the car reports kPa and the server keeps
storing kPa.

A "Power, 5-min average" card under Power: the time-weighted mean
battery power over the last five minutes, standing included, from the
same samples as the 5-minute range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:26:02 +02:00
nilsandClaude Opus 5.5 66fed48aae App 2.12.1: no scroll bars; energy since start
The pages' scroll bars never faded on the car: a ScrollView shows its
bar on every layout, and the live values lay the page out every two
seconds. StableScrollView, the root of every page, now has none.

A new "Energy since start" tile, left of "Usage since start", shows the
net kWh taken from the pack since the trip began, recuperation counted
back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:12:12 +02:00
nilsandClaude Opus 5.5 d711e0cfda App 2.12.0, server 2.5.0: enter settings on the phone
The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.

Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.

The config file also takes charging_price and usable_kwh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:59:03 +02:00
nilsandClaude Opus 5.5 9f184362f0 Server 2.4.0, app 2.11.1: ABRP forwarding removed from the server
The car sends live data to ABRP itself, and the car and the server go
over the same mobile connection: when the car cannot reach ABRP, it
cannot reach the server either, so the server's fallback bought
nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone;
the server only stores. The app stops sending abrpSentUtc (the server
ignores unknown fields, so 2.11.0 keeps working) and its texts no longer
promise a server fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:49:46 +02:00
nilsandClaude Opus 5.5 ca2f899403 App 2.11.0, server 2.3.0: live data straight to ABRP; settings regrouped
ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.

Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
  usable capacity and manual SoH (were on Consumption), charging price
  (was on Charging), Wi-Fi.
Charging and Live data get icons of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:15:21 +02:00
nilsandClaude Opus 5.5 087f193f8c App 2.10.2: infinity as the range while recuperating
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 11:01:02 +02:00
nilsandClaude Opus 5.5 3ae1e18a43 App 2.10.1: show negative usage while recuperating
Net regeneration over the window (or the trip) used to hide both
estimates behind "needs a few minutes of driving". It is a real
reading: the usage now shows as negative kWh/100 km, and the range as
"> 999 km" with "while recuperating" beneath it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 10:07:08 +02:00
nilsandClaude Opus 5.5 2a0c4c9675 App 2.10.0: range since start, usage in its own cards
"Range since start" replaces the range at current power: remaining
energy over the consumption since the car was switched on, standing
with the climate on included. A trip starts again after 10 minutes
without samples (the car was off) or when charging.

The kWh/100 km for both estimates moved out of the range tiles into
two cards of their own, directly below them; the overview still fits
the full-screen 1920x720 display without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 10:01:11 +02:00
nilsandClaude Opus 5.5 a5db5604d3 Code review fixes: app 2.9.0, server 2.2.0
Telemetry no longer loses queued batches. The server drops a bad value,
or a sample without a plausible time, instead of rejecting the batch,
and answers 200 with what it dropped. The app sends only values inside
the server's ranges (speed as a magnitude, so reversing is fine) and no
samples before the head unit's clock is set.

Server:
- Pairing hands out and accepts a token only after the device is saved;
  a failed save leaves the request pending.
- A paired token serves only the installation id it was paired with
  (403 otherwise); the shared INGEST_TOKEN still serves any.
- Wrong-password lockouts and open pairing requests are limited per
  client address (last X-Forwarded-For entry), so a stranger cannot
  lock the owner out; a car starting again replaces its own request.
- ABRP forwarding skips samples stamped ahead of the server's clock,
  which used to stop it until a restart.

App:
- Settings written to plaintext during a keystore failure move back
  into encrypted storage; the stored installation id wins.
- The installation id is derived from ANDROID_ID on first start, so a
  reinstall finds its history; a stored or imported id still wins.
- The scanned config file is deleted after import (it holds the token).
- GPS stops while live telemetry is off or the server unconfigured, and
  pauses while parked and idle, keeping the fix the car parked with.
- Range estimates use the monotonic clock.
- One HTTP helper (ServerHttp) for telemetry, sync and pairing.
- Last Turkish strings translated; mise logs shows the NilsMG4 tags.

Grafana reads InfluxDB with its own read-only token (new grafana-influx
Secret, created by hand as described in k8s/README.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:36:19 +02:00
nilsandClaude Opus 5.5 9bebf56b3a Docs: new package ID and APK names
OTA.md, mise.toml and docs/MG4_PLATFORM_INSTALL.md now use
com.stinnesbeck.nilsmg4(.unstable) and NilsMG4-*.apk. OTA.md says the
updater is switched off and that its publishing steps are upstream's
GitHub process; it also drops the old four-part version example.
CHANGELOG.md keeps the old ID as upstream history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:09:52 +02:00
nilsandClaude Opus 5.5 4b858e3c35 README for Nils' MG4
Rewritten for this fork: the car server, InfluxDB and Grafana setup,
pairing by QR code, the app's tabs, voice-key opening and full screen,
installing the platform-signed car build, the config file under the new
package ID, building with Nix, and credits. Adds a current screenshot
(2.8.2 at 1920x720, emulator).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:08:14 +02:00
nilsandClaude Opus 5.5 1b918e3c39 App 2.8.2: full screen fills the space the bars leave
On the MG4 the immersive flags hid SAIC's bars but left a black band
where they had been. SYSTEM_UI_FLAG_LAYOUT_STABLE reports the bars'
insets even while they are hidden, and the fitsSystemWindows root
padded itself by them. In full screen the flag is dropped and the root
no longer fits system windows; switching full screen off restores both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:00:58 +02:00
nilsandClaude Opus 5.5 8bad5fc024 Three-part version names for the car build
The unstable flavor appended upstream's CI build number as a fourth
part (2.8.0.0-unstable; always .0 without CI). Versions are now the
plain three-part defaultConfig version: 2.8.1-unstable, and build-car
names the APK NilsMG4-unstable-2.8.1.apk. The version code keeps the
x100000 factor (2.8.1 = 6700000): without it the code would fall below
the 6600000 already installed, and the installer refuses downgrades.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:55:08 +02:00
nilsandClaude Opus 5.5 3f1e196817 App 2.8.0: full screen
Immersive full screen while the app is in front: the system's top and
bottom bars are hidden, and a swipe from the edge shows them briefly.
Re-applied whenever the window regains focus. A "Full screen" switch in
the Upload tab (default on) turns it off.

On the Android 9 emulator the bars are hidden (system UI flags 0x1706),
stay hidden after leaving and returning, and the switch toggles them
through the UI. Whether SAIC's own bars on the MG4 obey the flags is
only known on the car; if they do not, nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:52:33 +02:00
nilsandClaude Opus 5.5 55710fb722 App 2.7.1: voice-key double press works; floating button removed
The car's key report, from the on-screen key log in 2.7.0:
android.intent.extra.hardkey.keycode = 287 (Integer),
android.intent.extra.hardkey.down (Boolean; one report on press, one on
release) and android.intent.extra.hardkey.longpress (Boolean). 2.7.0
only looked for short names like "keyCode", so it never found the code.
SteeringKeyReport now matches extras by the last part of their name,
case-insensitively, and is Android-free; its tests include the exact
double press logged on the car (releases 279 ms apart). Replayed in the
car's format on the emulator: double press opens the app, single and
long presses do not.

The floating button is removed (class, geometry and test, the overlay
permission, setting, switch and strings): it did not show over the MG4
launcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:38:36 +02:00
nilsandClaude Opus 5.5 af15b15db1 App 2.7.0: voice-key double press opens the app; no scroll jumps
Steering wheel: a double press of the voice key (code 287) opens the
app. The floating button from 2.6.0 does not show over the MG4
launcher, so this is the way back into the app. The head unit reports
keys as the broadcast com.saic.keyevent.hardkey.report, whose extras
are not documented: the receiver tries the usual names (and an
embedded KeyEvent), and counts one press per report whichever way the
up/down field turns out to work; long presses never open the app. The
last 20 reports are listed raw on the Logs tab, so if the guess
misses, a photo of that list gives the real format. A switch in the
Upload tab turns it off. DoublePressDetector (500 ms window) has 5
tests; on the emulator, simulated double presses open the app and
single presses, other keys and long presses do not.

Scrolling: pages jumped back to the top on every 2 s refresh. A stack
trace on the emulator showed why: ViewPager2's RecyclerView lays the
pages out while measuring, at a provisional height, and
ScrollView.onLayout clamps the position to 0. StableScrollView keeps
the driver's position across layouts and is now the root of every
page; the scrolled Charging page stays put across refreshes.

Vehicle tab: all six value tiles, including both range estimates, sit
in one row at the top, so nothing needs scrolling at 1920x720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:24:48 +02:00
nilsandClaude Opus 5.5 fde963533d Head unit is 1920x720, not 1920x1080
FIRMWARE.md, the mise emulator profile and SPECS.md said 1920x1080.
The MG4's display is 1920x720 at 160 dpi (an app window has been seen
at 1778x720, the car's sidebar taking the rest). The floating button's
geometry test now uses 1920x720.

The app itself assumed no height: checked on an Android 9 emulator at
1920x720, all tabs render (longer ones scroll), the sidebar holds all
seven tabs, and the floating button starts at 1840,312.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:58:28 +02:00
nilsandClaude Opus 5.5 11d53e7bc7 App 2.6.0: floating button to open the app
The MG4's launcher does not list third-party apps, so the app could
only be opened by reinstalling it. Now a small round MG-red button
floats over the car's screens: tap opens the app, drag moves it (the
position is kept and clamped to the screen), and it hides while the
app itself is open. A switch in the Upload tab turns it off.

The always-on service owns the overlay, so the button is there from
boot. It needs the overlay permission (SYSTEM_ALERT_WINDOW), which the
car build has through the system UID; without it, or if the window
manager refuses, the button stays away and nothing crashes, and the
Upload tab offers the system permission screen.

Tested on an Android 9 AOSP emulator at the head unit's 1920x1080 /
160 dpi with the car APK (installs as UID 1000): button appears over
the home screen, tap opens the app, hidden while open, drag moves it
without opening the app, position survives a force-stop, the switch
works through the UI. FloatingButtonGeometry has 3 unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:55:15 +02:00
nilsandClaude Opus 5.5 a53ea589b2 App 2.5.0: range estimates from current and 5-minute average power
The Vehicle tab gets two tiles next to the car's own range:

- Range at current power: remaining energy / (power / speed) right now.
- Range at 5-min average: remaining energy / (energy used / distance
  driven) over the last 5 minutes, integrated time-weighted from both
  the service tick (15 s) and the open screen (2 s), so it is ready as
  soon as the app opens.

Each shows the consumption it is based on (kWh/100 km). Remaining
energy is SOC x usable capacity, because the car publishes no pack
energy; the capacity is a new setting in the Consumption tab (default
50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when
they cannot mean anything: standing still, regenerating, too little
driving, or across a gap in the samples; over 999 km shows "> 999 km".

RangeEstimator is Android-free and has 7 tests. Also fixes the battery
card's note, which still said the SoH goes to MongoDB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:33:10 +02:00
nilsandClaude Opus 5.5 5f6cf1e901 Revert "Grafana: line charts in MG red"
Back to Grafana's default palette for the line charts, as before.
This reverts commit c5d61d1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:27:37 +02:00
nilsandClaude Opus 5.5 c5d61d1d11 Grafana: line charts in MG red
The tiles, bar charts and route map were already MG red; the line
charts still used Grafana's default palette. Now the first line of a
pair is MG red (#E0424C) and the second light red (#FF9A9E), as in
the app; the tyre chart adds deep (#A8323A) and pale (#FFD0D2) red.
Covers SoC/range, power/speed, voltage/current, tyre pressures and the
charging curve. Checked by rendering the Live and Charging dashboards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:25:35 +02:00
nilsandClaude Opus 5.5 582c084b35 Live dashboard: show what the car sends, compute pack energy
The car publishes no battery, cabin or outside temperature and no pack
energy (checked against the fields that reached InfluxDB). Instead of
panels that stay empty:

- Battery temperature tile -> Climate setpoint.
- Outside temperature tile -> Last update ("2 minutes ago").
- Energy in pack = SOC x usable capacity, from the hidden dashboard
  constant usableKwh = 50.8 (51 kWh battery).
- Temperatures chart -> Battery voltage and current.

Checked with a simulated drive carrying exactly the car's fields: all
11 panels return data, energy matches 61 % x 50.8 kWh. SPECS.md lists
the missing signals and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:17:41 +02:00
nilsandClaude Opus 5.5 f3de272c69 Grafana: map zooms to the route; plain hyphens in titles
- "Where the car was" fits the route again: in Grafana's fit view the
  zoom value is the maximum zoom, and 6 held it at country level. Now
  15 with 10 % padding. Checked by rendering the Live dashboard with a
  simulated 10 km drive.
- Dashboard titles use "-" instead of an em dash: "MG4 - Live",
  "MG4 - Consumption", "MG4 - Charging".
- SPECS.md: Grafana keeps its state in SQLite on its PVC, which is why
  it runs as one replica with Recreate; high availability needs
  PostgreSQL or MySQL first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:59:17 +02:00
nilsandClaude Opus 5.5 cee0eaeab3 Grafana: memory request 512Mi, limit 1Gi
It runs at about 406 MiB in the cluster, too close to the 512Mi limit:
long-range dashboard queries could get it OOM-killed. The request now
matches what it uses, so the scheduler reserves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:45:56 +02:00
nilsandClaude Opus 5.5 756ae65d0b Grafana: startup probe so a slow first start is not killed
Grafana's first start creates and migrates its database: 44 s at
0.5 CPU and 3 minutes at 0.1 CPU (measured with 13.2.3 on an empty
volume). With only 50m requested, the liveness probe (30 s delay,
3 x 30 s) restarted it mid-migration, in a loop, and readiness never
passed.

- startupProbe on /api/health allows up to 10 minutes; liveness and
  readiness start only after it succeeds.
- 5 s timeouts on all probes instead of the 1 s default.
- Requests raised to 250m CPU / 256Mi (measured about 240 MB in use);
  still no CPU limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:43:49 +02:00
nilsandClaude Opus 5.5 84fb3aac21 k8s: work with kubectl apply -f as well as -k
- The secrets template moves to k8s/secrets/, and the filled-in copy to
  k8s/secrets/secrets.yaml (gitignored). `kubectl apply -f k8s/` does
  not recurse, so neither the placeholder template nor the real secrets
  can be applied by accident.
- The server image digest is pinned in server.yaml instead of through
  kustomize's images override, which `-f` ignores (it would have pulled
  a nonexistent :latest).
- README and kustomization.yaml say to use `-k`: plain `-f` never
  creates the generated Grafana ConfigMaps.
- The IngressRoutes drop `tls: {}`, like the other routes on this
  Traefik: TLS is configured on the websecure entrypoint.

Manifests validated both ways (14 resources rendered with kustomize,
11 as plain files — the difference is the Grafana ConfigMaps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:36:17 +02:00
nilsandClaude Opus 5.5 46dccee1de App 2.4.3: pairing code visible on the car screen
The code below the QR code was not visible on the head unit: the dialog
stacked the text, a 240 dp QR code, the code and the status, and its
content could not scroll, so on the wide, low screen the lower part
was cut off. On wide screens the QR code now sits left of the text,
the content scrolls, and the code is also part of the instruction
sentence, so it is readable wherever the text is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:12:21 +02:00
nilsandClaude Opus 5.5 8a5813fa16 App 2.4.2: pairing dialog shows errors instead of closing
- On a failure the pairing dialog stays open and shows the reason in
  red (Cancel becomes Close); before, it closed at once and the reason
  was only in the Server tab's status line.
- "Pairing is not enabled" only when the server says so. Any other 404
  (a server older than 2.1.0, or a proxy's page) is reported as a
  server without pairing.
- Pairing and Test connection name the problem: a server name that
  does not resolve (Test connection used to say "No internet
  connection"), a server that does not answer, or the HTTP status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 20:56:02 +02:00
nilsandClaude Opus 5.5 6f57351866 Use the traefik-internal ingress class
Both IngressRoutes (server and Grafana) now use traefik-internal, like
the rest of *.k8s.stinnesbeck.com, instead of the traefik-external
placeholder. README and SPECS updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 18:16:52 +02:00
nilsandClaude Opus 5.5 315586f94c Move the server to car.k8s.stinnesbeck.com
The server's host is car.k8s.stinnesbeck.com, not car.stinnesbeck.com:
IngressRoute, PUBLIC_URL (so the pairing QR code points there), the
app's pre-filled server URL and hint, and the docs. Grafana's
placeholder host follows to car-grafana.k8s.stinnesbeck.com, so one
*.k8s.stinnesbeck.com default certificate covers both. App 2.4.1 for the
new default; the server image is unchanged (its URL comes from the
manifest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 18:08:35 +02:00
nilsandClaude Opus 5.5 59ee2cae3b Deploy server 2.1.0; keep filled-in secrets out of git
- kustomization pins mg4-server 2.1.0 by digest; server.yaml sets
  PUBLIC_URL for the pairing QR code; the secrets template gains
  APPROVAL_PASSWORD.
- Filled-in Secrets now go into k8s/secrets.yaml, which is gitignored;
  the template stays on placeholders. This follows a leak where real
  values typed into the template were committed and pushed (amended and
  force-pushed since; both values regenerated).
- SPECS.md records pairing and the incident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:52 +02:00
nilsandClaude Opus 5.5 c578a882bd App 2.4.0: pair with the server by QR code
The Server tab gets "Pair with phone" right under the server URL, which
is now pre-filled with https://car.stinnesbeck.com. It opens a dialog
with a QR code and the short code, polls the server, and on approval
saves this car's own token like Save would — nothing to type in the
car. Denied, expired, and pairing switched off on the server are each
reported. PairingClient is tested against a stand-in for the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:52 +02:00
nilsandClaude Opus 5.5 d5f68e1bd9 Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):

- POST /v1/pair/start opens a request for an installation and returns
  a short user code and the /pair URL for the car's QR code;
  POST /v1/pair/poll returns pending, the car's own token (once),
  denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
  (strict CSP, no framing). Five wrong passwords lock approvals for a
  minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
  (measurement "device"), and the device is persisted before the car
  can collect the token. Tokens are loaded at startup, so a restart
  keeps paired cars working; the server refuses to start if they cannot
  be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
  the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.

31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:19 +02:00
nilsandClaude Opus 5.5 4fe29189c0 Pin the pushed server image; drop the pull secret
mg4-server:2.0.0 is on git.stinnesbeck.com and pulls anonymously, so
the Deployment no longer references a registry pull secret. The image
is pinned by digest (sha256:6feea777…) so a re-pushed tag cannot
change what the cluster runs. README and secrets template updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:14:53 +02:00
nilsandClaude Opus 5.5 e874578e54 Replace the turquoise with MG red tones
ev_cyan becomes a light red (#FF9A9E, for values and secondary
highlights, readable on black) and ev_cyan_deep a deep red (#3A1215,
version badge). Turquoise hardcoded in the battery card, switch track,
input panel, car outline, background glow and charging-graph grid is
replaced with matching red tones, as are two gold leftovers (tab
outline, second background glow). The mint "OK" status colour and the
amber charging curve are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 15:45:18 +02:00
nilsandClaude Opus 5.5 5473ab1c2b Dashboards in Grafana; clean up after the Next.js app
- k8s/grafana.yaml: Grafana 13.2.3 with a 1Gi PVC, sign-up and
  anonymous access off. The InfluxDB datasource (URL and token from the
  environment) and three dashboards — Live, Consumption, Charging — are
  provisioned from k8s/grafana/ via kustomize-generated ConfigMaps.
- server.yaml for the Go server (port 8080, UID 65532, smaller limits),
  image tag 2.0.0. The NetworkPolicy now admits Grafana to InfluxDB.
- ingress.yaml: IngressRoutes for car.stinnesbeck.com and Grafana
  (placeholder host car-grafana.stinnesbeck.com) with tls: {}, using
  Traefik's default certificate; the cert-manager Certificate is gone.
- secrets.example.yaml: drops the dashboard secrets, adds grafana-admin.
- AGENTS.md loses its Next.js block; SPECS.md describes the new setup.

The removal of web/ (the Next.js app) and of the stale .next/ cache
upstream had committed already landed in 51e6f6f, which was staged
together with the Go server by mistake.

Checked end to end in podman (Go server + InfluxDB 2.7.12 + Grafana
13.2.3): all 25 dashboard queries return the expected data, and the
dashboards were rendered with headless Chromium. Manifests validated
offline with kustomize and kubeconform. Nothing applied to a cluster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 15:18:09 +02:00
nilsandClaude Opus 5.5 51e6f6fd48 Server: rewrite in Go as an API-only service
Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.

- Go 1.26 standard library (net/http routing, log/slog) plus the
  official InfluxDB 2 client. Fails closed at startup on a missing
  InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
  queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
  handlers through a fake store. govulncheck clean (x/net 0.60.0,
  toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
  8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 15:17:42 +02:00
nilsandClaude Opus 5.5 538d03361d Translate the dashboard to English; euro instead of lira
Dashboard: every label, empty state, chart legend and the login page
are English; numbers and dates use en-GB (24-hour clock, day-month
dates), money is EUR, percentages read 80% instead of %80, durations
"2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it.

Car app: the charging tab's price suffix, validation message and total
cost use € instead of ₺.

Checked by rendering all three pages from the 1.1.2 image against
InfluxDB 2.7.12 in podman.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:39:59 +02:00
nilsandClaude Opus 5.5 818442db08 Rename the web app from FatihsMG4 to Nils' MG4
Browser title, login heading, hidden password-manager username, session
cookie (nilsmg4_session) and package name. The README keeps the credit
to fatihdonmezdev/FatihsMG4. Server version 1.1.1; the manifests pin it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:34:16 +02:00
nilsandClaude Opus 5.5 e39d9cdcf9 Replace the muted gold accent with MG red
ev_accent is now #E0424C, the red of the launcher icon (about 4.6:1 on
the black background, enough for accent-coloured text; the darker MG
brand red is about 3:1). ev_on_accent and ev_gold_surface follow with a
dark red tint; the gold hardcoded in two drawables and the consumption
period buttons is replaced too. AGENTS.md's visual direction updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:29:01 +02:00
nilsandClaude Opus 5.5 6145736c97 App 2.3.0: Server tab, live telemetry to the car server
- New Server tab: server URL, ingest token and installation id, with
  Save, Test connection (health, then a read-only token check) and the
  config-file import that upstream removed (app folder / USB stick first,
  document picker as fallback). Settings are kept encrypted and read on
  every use, so the service picks up a change without a restart. Each
  installation generates its own id; the inherited upstream UUID and
  the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
  UploadCadence, UploadSettings, GPS) and sent in batches to
  POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
  ext_temp, voltage and current. Unsent samples queue in memory (about
  8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
  the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
  for the deleted AbrpApi and HeadUnitWeather removed; new tests for
  ServerConfig and TelemetryQueue. Lint passes.

Not yet tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:23:04 +02:00
nilsandClaude Opus 5.5 c253876ed2 Add Kubernetes manifests for car.stinnesbeck.com
Namespace, InfluxDB 2.7.12 StatefulSet with PVC and a NetworkPolicy
that admits only the server, the server Deployment (non-root, read-only
root filesystem), a cert-manager Certificate from letsencrypt-prod and a
Traefik IngressRoute. Secrets are a template only. Nothing is applied;
k8s/README.md has the steps. Server version bumped to 1.1.0.

Validated offline with kustomize and kubeconform (including the
cert-manager and Traefik CRD schemas). The InfluxDB container was tested
locally as UID 1000 with no capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:11:58 +02:00
nilsandClaude Opus 5.5 4ea66cf424 Server: InfluxDB 2 storage, live telemetry endpoint, ABRP forwarder
- MongoDB replaced by InfluxDB 2 (one bucket, installationId tag).
  Consumption is stored per chunk at its recordedAt and summed on read,
  so a chunk the car re-sends from its cache overwrites itself instead
  of being counted twice ($inc did count it twice). Lifetime is a
  running total derived on read.
- POST /v1/telemetry takes batches of samples in ABRP's tlm fields; every
  field is bounded, unknown fields are dropped, unread signals stay absent.
- After a successful write the newest fresh sample is forwarded to ABRP
  with ABRP_API_KEY / ABRP_USER_TOKEN from the server environment; rate
  limited, never queued. The car no longer needs ABRP credentials.
- Self-hosting: standalone output, Dockerfile (tests run in the build,
  runs as UID 10001), .env.example and README for the new setup.
- Dev shell gains Node 22 and InfluxDB 2.

Verified locally against InfluxDB 2.7.12: all endpoints, retry
idempotency, and the dashboard pages. Unit tests: 17 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:09:29 +02:00
nilsandClaude Opus 5.5 d4fb4ba3af Plan the car server (InfluxDB 2, ABRP forwarder, k8s manifests) in SPECS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:51:07 +02:00
nilsandClaude Opus 5.5 de2428786a Journal diagnostics and upstream ABRP removal in SPECS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:47 +02:00
nilsandClaude Opus 5.5 10029623e3 Remove on-screen diagnostics from the Logs tab
They served their purpose: vehicle values show on the car. This reverts
commit b2a5b32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:36 +02:00
nilsandClaude Opus 5.5 b2a5b32b08 Add on-screen diagnostics to the Logs tab
Without adb there was no way to see why vehicle values stay blank:
refreshVehicle() swallowed every exception. The Logs tab now shows the
package, version, UID (and whether it is the system UID), signer
SHA-256, firmware, car permission grants, the last snapshot and read
error, and the newest 200 entries of the vehicle library's AppLogger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:26:39 +02:00
nilsandClaude Opus 5.5 a707b590f7 Rename package and APKs to NilsMG4, credit Fatih, add SPECS.md
- applicationId com.evsuite.abrp -> com.stinnesbeck.nilsmg4 (the car
  build is .unstable); the Java namespace is unchanged. Installs next to
  upstream's app rather than over it.
- APK names NilsMG4-*.apk, debug builds included; build-car follows.
- OTA install-result action, cache/download names, User-Agent and log
  tags follow the new name.
- About credits fatihdonmezdev / FatihsMG4.
- SPECS.md journals what changed and what is still to do, starting
  with pointing the About link and QR code at git.stinnesbeck.com/nils/mg4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:08:09 +02:00
nilsandClaude Opus 5.5 3da3fa9f49 Rename app to "Nils' MG4"
Changes the launcher label, header brand caption, About title, logo
description and OTA download labels. Package ID, APK file names, log
tags and the repository link are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:02:18 +02:00
nilsandClaude Opus 5.5 3af337ca89 Disable OTA update checks by pointing at an unresolvable host
The updater stays in place, but its releases URL now uses the .invalid
TLD (RFC 2606), so checks fail instead of offering upstream's Turkish
builds over this one. Swap in a fork's releases API to re-enable it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:59:52 +02:00
nilsandClaude Opus 5.5 8e7d8b8627 Add build-car: unstable APK signed with the AOSP platform key
The MG4 head unit's platform certificate (c8a2e9bc…192ab8, per
tools/README.md) is the public AOSP test key. build-car fetches that key
into the gitignored tools/ directory from a pinned android-9.0.0_r1 tag,
builds assembleUnstableDebug, zipaligns and signs it, and refuses to
finish unless both the key's certificate and the resulting signer match
the expected SHA-256. Output lands in build/car/ with a .sha256 sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:51:58 +02:00
nilsandClaude Opus 5.5 9d647f2ce1 Match dev shell hint to the documented build commands
Drops the unit-test hint, since the test sources do not compile upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:49:17 +02:00
nilsandClaude Opus 5.5 bac887bfa6 Add Nix flake dev shell for building on NixOS
Provides JDK 21 and an Android SDK (platforms 36/36.1, build-tools
36.0.0) via `nix develop`. Points AGP at the SDK's aapt2 and Gradle at
the Nix JDK, since the binaries they would otherwise download do not
run on NixOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:46:37 +02:00
nilsandClaude Opus 5.5 864ac612da Translate app UI from Turkish to English
Translates string resources, hardcoded layout text and user-facing
strings in Java sources (toasts, dialogs, status labels, upload log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:38:00 +02:00
207 changed files with 12418 additions and 5246 deletions
+4
View File
@@ -39,3 +39,7 @@ mise.local.toml
/EVHardware/build
/EVHardware/.gradle
/EVHardware/**/build
# Filled-in Kubernetes Secrets (copy of k8s/secrets/secrets.example.yaml). Never commit.
/k8s/secrets.yaml
/k8s/secrets/secrets.yaml
-1
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
-1
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
-1
View File
@@ -1 +0,0 @@
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
-3
View File
@@ -1,3 +0,0 @@
{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"}
{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""}
{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "commonjs"
}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": []
}
-1
View File
@@ -1 +0,0 @@
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
@@ -1,15 +0,0 @@
globalThis.__BUILD_MANIFEST = {
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
};
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"sortedMiddleware": [],
"functions": {}
}
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
-6
View File
@@ -1,6 +0,0 @@
{
"app": {},
"appUsingSizeAdjust": false,
"pages": {},
"pagesUsingSizeAdjust": false
}
-1
View File
@@ -1 +0,0 @@
{}
-5
View File
@@ -1,5 +0,0 @@
{
"previewModeId": "a508c818777951bed52c0170453ef7e3",
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
}
@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
}
@@ -1,8 +0,0 @@
self.__BUILD_MANIFEST = {
"__rewrites": {
"afterFiles": [],
"beforeFiles": [],
"fallback": []
},
"sortedPages": []
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
@@ -1 +0,0 @@
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-55
View File
@@ -1,55 +0,0 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = never
type PageRoutes = never
type LayoutRoutes = never
type RedirectRoutes = never
type RewriteRoutes = never
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
interface ParamMap {
}
export type ParamsOf<Route extends Routes> = ParamMap[Route]
interface LayoutSlotMap {
}
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
declare global {
/**
* Props for Next.js App Router page components
* @example
* ```tsx
* export default function Page(props: PageProps<'/blog/[slug]'>) {
* const { slug } = await props.params
* return <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}
+107 -109
View File
@@ -1,132 +1,130 @@
# AGENTS.md — EVABRPUploader
# AGENTS.md — Nils' MG4
Context for AI agents and new contributors working in this repository.
EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative
workspace `DESIGN.md` apply; this file contains only telemetry-specific additions.
## App-specific visual direction (2.2.6)
The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards,
muted gold and cyan accents. These app resources intentionally override the shared
DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them
with the shared token generator. Use static gradients rather than continuous effects;
keep large touch targets, readable text and null telemetry shown as a dash.
Wide layouts start at 1000dp; narrower windows use a bottom navigation bar.
Start here. What this repository is, how its parts fit, how to build, test and ship, and the
rules for changing it. Details live in the documents linked below; `SPECS.md` is the journal
(status, to-dos, a dated log of every change) and is updated with each change.
## What this is
An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and
pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712).
Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status is
**unresolved** and that is a real constraint, not paperwork.
## Non-negotiables
1. **Telemetry is read-only.** No vehicle settings may be written. Window automation
was removed in 2.2.5 at the user's request, including its service and door listener.
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
tells ABRP the battery is empty and wrecks a live route plan.
3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather
than assume.
4. **Never let a credential reach a URL, a log line, or a crash report.**
5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable.
Every car call goes through reflection with try/catch; keep it that way.
6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no
alarms. The GPS subscription and the upload tick both cost power — keep them in step.
## Architecture
An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to:
```
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP
CarPropertyAdapter reflection bridge to android.car (not on the compile classpath)
TelemetryPayload pure JSON building — no Android types, unit-tested
UploadCadence when a tick actually uploads — pure, unit-tested
UploadSettings user cadence configuration
UploadLog last 20 attempts + derived service state — pure, unit-tested
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
AbrpApi HTTP; credentials in the POST body, never the query string
MainActivity configuration, service state, upload log
MG4 head unit (Android 9, AAOS, MT2712)
app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana
│ │ relays app releases from the forge
└──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner)
```
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
anything inside the service effectively cannot be tested without a car.
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
`EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
server and to ABRP, and updates itself.
- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
dashboards, Traefik routes). See `k8s/README.md`.
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
## Source sets
Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch
`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
| Set | Contents | Ships in |
|---|---|---|
| `main` | everything shared | all builds |
| `stable` | `UpdateHook` no-op | stable channel |
| `unstable` | active GitHub pre-release updater and PackageInstaller result receiver | unstable channel |
| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only |
| `testUnstable` | OTA policy tests | test only |
## Rules
The stable channel does not merely disable self-update — the updater is **not in the
APK**.
1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner
applies them. No `kubectl` against the cluster, not even read-only, unless asked.
2. **Secrets.** Filled-in values only in `k8s/secrets/secrets.yaml` (gitignored);
`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.
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.
6. **No credential in a URL, a log line or a crash report.** Tokens go in headers or bodies.
7. **The head unit must stay stable.** Every car call is reflection with try/catch; no
blocking car reads on the main thread.
8. **Power.** No wake locks or alarms; GPS pauses while parked and idle.
9. **Honesty.** Most behaviour cannot be verified off the car. Say what was tested (unit tests,
emulator, podman) and what was not. "Not tried on the car" is an acceptable answer.
## Firmware generations
## The car (confirmed on the owner's car, firmware `SWI68-29958-1300R69`)
MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they
differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing
must tolerate a property simply not existing.
- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
- Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`.
- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report`
(`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**.
- Not published by the car: battery, cabin and outside temperature, pack energy and capacity,
paint colour (only `ro.build.mtk2712.vehicle_type = AS28`). See `SPECS.md`, `FIRMWARE.md`.
- No third-party apps in the MG launcher: the app opens by a double press of the voice key.
- Its internet comes from the car itself (no Wi-Fi needed).
**Two read paths, split by whether the signal is firmware-agnostic:**
## The app
| Signal | Read path | Firmwares |
|---|---|---|
| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally |
| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** |
| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them |
| Tab | Contents |
|---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread |
| 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 |
| Logs | upload log |
`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects
the generation and picks the right underlying API. Fields it covers therefore work on every
supported firmware.
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
limits), `AbrpApi`/`AbrpConfig`, `ServerHttp`/`ServerConfig`/`PairingClient`/
`PhoneSettingsClient`, `ConsumptionTracker`/`ConsumptionCloudClient`, `ChargeSessionTracker`,
`RangeEstimator`, `SecurePrefs`, `ConfigImport`, `SteeringKeyListener`, `MainActivity` (UI).
Updater (car build only): `app/src/unstable/` `OtaUpdater`, `UpdateHook`, `UsbUpdate`.
**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in
`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware
has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it;
until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on
another firmware they may silently return nothing (handled safely — the field is omitted, but
SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any
other generation.
**Put logic in pure, unit-tested classes** (no Android types); the service and activity are
hard to test without a car.
## Working here
**UI conventions:** OLED black, glass cards, an accent the user picks from the MG4 paints —
always `?attr/evAccent…` (`res/values/attrs_accent.xml`, overlays in `accents.xml`), never a
hard-coded accent hex. All UI text in `strings.xml`. 72 dp touch targets. Values right-aligned
with tabular digits. Drop-downs are Material exposed dropdown menus and dialogs go through
`showKeepingFullScreen` — a Spinner or a plain dialog brings the car's bars back. Android 9
crashes on gradients with theme colours: use layers.
```bash
mise run check # what CI runs: permission gate + lint + unit tests
mise run build
mise run logs # adb logcat, filtered to this app
**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB
install). The car always gets `unstable`.
## Build, test, ship
```sh
nix develop # JDK 21, Android SDK, Go
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
publish-car # pre-release car-<version> on the forge (needs a token)
cd server && go vet ./... && go test ./... && govulncheck ./...
```
Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`,
a stale Gradle daemon started by VS Code's Java extension is being reused —
`pkill -f GradleDaemon` and retry.
- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with
every build that goes to the car (a lower code is refused as a downgrade).
- **Getting it to the car:** in-app update (Settings → Check for updates, via the server), or
Install from USB stick (APK + `.sha256` in the stick's top folder), or the first time through
the Bluetooth-settings file manager (`README.md`). `OTA.md` has the whole flow.
- **Server image:** `podman build` in `server/`, push to `git.stinnesbeck.com/nils/mg4-server:<v>`,
pin the pushed digest in `k8s/server.yaml`. Validate manifests offline with
`kustomize build k8s | kubeconform` (see `k8s/README.md`).
- **Emulator:** `nix run .#car-emulator` (add `-no-window` for headless) starts an Android 9 AOSP
emulator at 1920×720, 160 dpi on port 5560 (state in `build/emulator/`). Its image shares the
car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640`
gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has
no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle
screen; use screenshots.
- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit
staging, commit, push.
## Things that will bite you
## Documents
- **`android.car` is not on the compile classpath.** Everything car-related is reflection.
R8 cannot see those uses, so `proguard-rules.pro` keeps them by name.
- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real
implementation is added as a test dependency; do not remove it.
- **`service_running` in preferences goes stale-true** after a force-kill. Use
`AbrpUploadService.isRunning()`.
- **Permission changes fail CI** until allowlisted with a justification.
## Honesty requirements
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
tested and what was not. "Unit tests pass, not tried on the car" is the expected and
acceptable answer. Do not imply road-testing that did not happen.
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
| File | What |
|---|---|
| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log |
| `README.md` | user-facing overview, install, setup, config file |
| `OTA.md` | releases, the updater's checks, USB install |
| `FIRMWARE.md` | the head unit, firmware generations, display |
| `server/README.md`, `k8s/README.md` | API, configuration, deployment |
| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain |
| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history |
+2 -2
View File
@@ -39,7 +39,7 @@
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
overnight affair the head unit sleeps through, so its curve, duration and energy were
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` rather than showing
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` ("AC charging (not metered)") rather than showing
the previous DC session's figures next to it.
### Fixed
@@ -246,7 +246,7 @@ All notable changes to this project are documented here. Format follows
- OTA installs now go through the platform `PackageInstaller` instead of shelling out to
`pm install -r`. The old path handed the package manager service a file inside the app's
private cache, which it cannot read from its own process, and every such failure was
reported as `Kurulum reddedildi (imza uyuşmuyor?)` — a signature verdict on a correctly
reported as `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)") — a signature verdict on a correctly
signed build. A session streams the archive from this process, so no second process has
to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the
same problem on the same head unit.
+2 -3
View File
@@ -91,6 +91,5 @@ say what the failure looked like, so the next person recognises it.
## Licensing
Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because
the upstream fork source has none. Contributions are accepted on the understanding that
they are offered under MIT once that is settled.
Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence,
except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence.
@@ -335,18 +335,18 @@ object EVHardware {
}
/**
* État d'ignition courant, en valeurs [IgnitionState] (AAOS).
* Current ignition state, as [IgnitionState] (AAOS) values.
*
* CarPropertyManager d'abord. Mais SWI68 n'expose pas IGNITION_STATE via CPM : la
* property revient vide, et l'état d'allumage passait pour illisible alors que Katman5
* le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`)
* pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE.
* CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the
* property comes back empty, and the ignition state looked unreadable although Katman5
* reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while
* the diagnostics classed IGNITION_STATE as NOT_READABLE.
*
* D'où le repli sur le dernier état livré par IVehicleConditionService, converti :
* Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les
* deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre).
* Hence the fallback to the last state delivered by IVehicleConditionService, converted:
* Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two
* scales do not line up (RUN is 2 in one, OFF is 2 in the other).
*
* Returns -1 si rien n'est lisible, 0 si la property existe mais ne dit rien.
* Returns -1 if nothing is readable, 0 if the property exists but says nothing.
*/
fun getCurrentIgnitionState(): Int {
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
@@ -356,7 +356,7 @@ object EVHardware {
return vcmIgnitionAsAaos() ?: global
}
/** Le dernier état Katman5 traduit en [IgnitionState], ou null s'il n'y en a pas eu. */
/** The last Katman5 state translated into [IgnitionState], or null if there was none. */
private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) {
CarIgnitionItem.OFF -> IgnitionState.OFF
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
+7 -2
View File
@@ -11,7 +11,12 @@ development. It must not be treated as a vendor specification.
| Android | 9 (Pie) — **API 28** |
| Chipset | MediaTek **MT2712** |
| Device | `mt2712_saic_eh32` (eh32 = MG4) |
| Emulator display profile | **1920×1080 @ 160 dpi** |
| Display | **1920×720 px @ 160 dpi**; the app window is **1778×720 px** (≈1778×640 dp for layouts), the car's own sidebar taking the rest. Confirmed on the car 2026-10-18 (read with app 2.16.2, `SWI68`). |
| Emulator display profile | **1920×720 @ 160 dpi** |
| `ro.build.mtk2712.vehicle_type` | `AS28` (a vehicle type code; the paint colour is not among the system properties) |
EVProfile's README gives the display as 1280×480. That is 1920×720 px at 240 dpi expressed in
dp; on this car the density is 160 dpi, so it does not apply here.
## Signing caveat
@@ -42,5 +47,5 @@ follow the same path. See `AGENTS.md` for the full per-signal split.
## Emulator fidelity
The `mise` emulator tasks use an API 33 automotive AVD for the car service and an API 28
AVD at **1920×1080 @ 160 dpi** for the screen. Neither exposes the SAIC vendor properties,
AVD at **1920×720 @ 160 dpi** for the screen, as on the car. Neither exposes the SAIC vendor properties,
so vehicle-specific behaviour still requires on-vehicle validation.
+2
View File
@@ -2,6 +2,8 @@ MIT License
Copyright (c) 2026 Leon Kernan (original ABRP_Uploader)
Copyright (c) 2026 EVABRPUploader contributors
Copyright (c) 2026 FatihsMG4 contributors
Copyright (c) 2026 Nils Stinnesbeck (Nils' MG4)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+41 -11
View File
@@ -1,19 +1,49 @@
# Licence
This project is released under the **MIT Licence** — see [`LICENSE`](LICENSE).
Everything in this repository is released under the **MIT Licence** — see
[`LICENSE`](LICENSE) — **except the `EVHardware/` library**, which has its own licence (below).
## Attribution
## Where it comes from
EVABRPUploader is a fork of Leon Kernan's `ABRP_Uploader`, and a meaningful part of
the original work survives in it. The copyright line names both him and this fork's
contributors, and MIT requires that notice to travel with any copy or substantial portion
of the software. Keep it.
Nils' MG4 is a fork of a chain of forks, all MIT:
The upstream repository carried no licence file of its own. Applying MIT here is the
project owner's decision; if Leon Kernan ever objects, the right response is to honour
that rather than argue about it.
- [Leon Kernan's `ABRP_Uploader`](https://github.com/lkernan/ABRP_Uploader), the original. It
carries the MIT Licence since 2026-07-29
([its LICENSE](https://github.com/lkernan/ABRP_Uploader/blob/master/LICENSE), commit
`75d8f2bc`). Upstream's note that it had no licence predates that.
- malys' EVABRPUploader, then
[fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), both under MIT.
- This fork, under MIT as well.
MIT requires the copyright notice to travel with any copy or substantial portion of the
software. `LICENSE` names all of them; keep every line.
## The EVHardware library
`EVHardware/` (the shared vehicle layer from EVSuite) is source-available under the
**PolyForm Noncommercial License 1.0.0** — see [`EVHardware/LICENSE`](EVHardware/LICENSE) and
[`EVHardware/LICENSE.md`](EVHardware/LICENSE.md). Commercial use is not permitted, and its
`Required Notice: Copyright (c) 2026 EVHardware contributors` must go with any copy.
The car app links EVHardware into its APK, so the **built app** may only be used and passed on
non-commercially, whatever the MIT parts would allow on their own. The server, the Kubernetes
manifests and the docs do not contain EVHardware and are plain MIT.
EVHardware's own notes say parts of it come from EVProfile, "itself a fork whose upstream
licence situation is unresolved". Checked on 2026-10-18, that is settled for the code in
question:
- [malys/EVProfile](https://github.com/malys/EVProfile) is MIT. It is a fork of
[SliDeeN/MG4Control](https://github.com/SliDeeN/MG4Control), and its last merge from
MG4Control is a commit of 2026-08-02.
- MG4Control is MIT up to and including 2.6.5 (released 2026-08-05; its licence file dates
from 2026-06-24) and GPL v3 or later from 2.6.6 (2026-09-02). Its `LICENCE.md` states that
the earlier versions stay MIT for good.
So what EVProfile took from MG4Control dates from MG4Control's MIT period. This covers the
lineage EVHardware names; it is not an audit of every line in EVHardware.
## No warranty
MIT disclaims all warranty and liability, and this software runs on a **vehicle** — see
[`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely.
Both licences disclaim all warranty and liability, and this software runs on a **vehicle** —
see [`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely.
+79 -138
View File
@@ -1,163 +1,104 @@
# Publishing an OTA release
# Updating the car app over the air
How to ship an update the car will install by itself. Written for whoever does this
next — probably me, having forgotten the details.
How a new car build reaches the head unit without the Bluetooth-settings detour, and what to
check when it does not.
## First upgrade from 2.2.3 or older
```
build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnesbeck.com/nils/mg4)
│ read with FORGE_TOKEN (read-only, server Secret)
▼
car server /v1/app/releases, /v1/app/assets/…
│ the car's own paired token
▼
app: refresh button → check → SHA-256 → install
```
Install the platform-signed 2.2.7 APK manually once if the car still runs 2.2.3 or older.
Those builds use the old `pm install` path; publishing a newer APK does not repair the
installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions.
Keep the unstable package ID, system shared UID and signing certificate when upgrading.
The repository stays private and the forge token stays on the server: the car asks its own
server, with its own token, and never holds a forge credential. The server only relays
bytes. Every check that decides whether code gets installed is in the app.
The 2.2.5 build removes window automation. It also distinguishes failed release checks
from a successful check with no newer release. On 2026-09-29, GitHub still published only
2.2.3 at the last live check. The local 2.2.7 build includes these fixes and the redesigned
UI; it becomes available to OTA only after publication as a pre-release asset named
`FatihsMG4-unstable-2.2.7.apk`, together with
`FatihsMG4-unstable-2.2.7.apk.sha256`.
## Publishing a release
The vehicle artifact uses `com.evsuite.abrp.unstable`, `android.uid.system`, and the
DriveHub_Kamera platform key. Its certificate SHA-256 must be
`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`.
The stable build used for a Samsung UI preview is a separate package without OTA or the
vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName`
is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`.
2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head
unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-<version>.apk` and
its `.sha256`.
3. **Publish:** `publish-car`. It creates the pre-release `car-<version>` on the current
commit and attaches the APK and the `.sha256`. It needs a forge token that may write the
repository's releases, in `~/.config/nilsmg4/forge-token` (or `$FORGE_TOKEN`); the token is
only sent in a header, never printed. It refuses a version that is already published.
## What the updater actually looks for
By hand, the same: in the forge's web UI create a release with tag `car-<version>`, tick
**pre-release**, and attach both files with exactly their names.
`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases`
and walks **every** entry, keeping the highest version rather than the first match. A
release is a candidate only if all of these hold:
## What the updater accepts
| Requirement | Where it is enforced | What happens otherwise |
The app asks the car server for the releases (`GET /v1/app/releases`) and walks every one,
keeping the highest version:
| Requirement | Where it is enforced | Otherwise |
|---|---|---|
| Marked **pre-release** | `release.optBoolean("prerelease")` | Skipped entirely |
| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored |
| Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored |
| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" |
| Download host on the allowlist | `isAllowedUrl()` | Refused and logged |
| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform |
| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused |
| Marked **pre-release**, not a draft | `selectUpdate` | Skipped |
| Asset name contains `unstable` and ends `-<version>.apk` | `ASSET_VERSION` regex | Ignored |
| Version strictly higher than installed | `isNewer()` | "Up to date" |
| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused |
| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted |
| The archive is this app's package | `archiveIsThisPackage()` | Refused |
| Signed with the **same certificate** as the installed app | Android's installer | Not installed |
The last one is the one that bites. The car runs a platform-signed build; an APK signed
with a debug key — or anyone else's key — is downloaded, rejected and deleted. Sign every
release with the same platform key or the update silently never lands.
The version comes from the **asset file name**, not the tag.
The version comes from the **asset filename**, not the git tag. The tag can stay
`unstable` forever and be moved; the filename is what identifies a build.
## Installing
## Release checklist
Press the refresh button in the app's top bar. The app checks, downloads the APK into
**Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller`
session. As the system user it may install without asking; if the platform wants a
confirmation, the confirmation screen opens. When a session cannot start at all, the file
manager opens in Downloads (or, where the head unit has none, open the APK there with the
Files app). The verified APK stays in Downloads either way.
1. **Raise the version.** `app/build.gradle`, `defaultConfig`:
At start the app only checks, and shows "Update available" when there is one. It never
downloads or installs on its own, and the upload service never checks: installing restarts
the app, which must not happen mid-drive on its own.
```gradle
versionCode 24 // must increase — Android orders updates by this, not by name
versionName "2.2.7"
```
## Installing from a USB stick
The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7.0-unstable`.
Check what the installed build reports before picking a number: the app's About
dialog shows it, and `isNewer` compares numerically segment by segment.
For development, without the forge: copy `NilsMG4-unstable-<version>.apk` **and** its `.sha256`
from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB
stick** on the Settings tab. The app takes the newest such file that is newer than the installed
version, checks it against the `.sha256`, checks the package, and installs it with the same
installer session as an over-the-air update. The car build only; the stable build has no button.
2. **Build.**
```bash
./gradlew :app:assembleUnstableDebug
```
3. **Sign with the platform key.** Options come *before* the input APK — apksigner
rejects the other order.
```bash
BT=~/Android/Sdk/build-tools/35.0.0
# Local copies from DriveHub_Kamera/tools; private and gitignored.
KEYS="$PWD/tools"
"$BT/zipalign" -f 4 \
app/build/outputs/apk/unstable/debug/app-unstable-debug.apk aligned.apk
"$BT/apksigner" sign \
--key "$KEYS/platform.pk8" \
--cert "$KEYS/platform.x509.pem" \
--out FatihsMG4-unstable-2.2.7.apk \
aligned.apk
rm aligned.apk
```
The output filename **is** the version the updater reads. Name it
`FatihsMG4-unstable-<version>.apk`, then create its required integrity sidecar:
```bash
shasum -a 256 FatihsMG4-unstable-2.2.7.apk \
> FatihsMG4-unstable-2.2.7.apk.sha256
```
4. **Verify the signer** before uploading. It must say `CN=Android`:
```bash
"$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk
```
5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release:
- tag: `unstable` (reuse it; the rolling tag is the design)
- **tick "Set as a pre-release"** ← the whole thing is inert without this
- attach the signed APK and its `.sha256` sidecar
6. **Install it.** Open the app on the head unit and press the refresh button in the top
bar. The dialog shows download progress, verifies SHA-256, then reports that the
PackageInstaller session has started.
On the Android 9 emulator (car build, system user) this installed without asking, from the
storage's top folder; on the car the stick's top folder is used the same way.
## When it says something else
| Toast | Meaning |
| Message | Meaning |
|---|---|
| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
| `Güncelleme reddedildi: SHA-256 doğrulaması yok` | APK has no matching sidecar, or the sidecar is invalid/unreachable. |
| `İndirme veya SHA-256 doğrulaması başarısız` | Download failed or its bytes do not match the published hash. |
| `Kuruluyor: 2.2.3 — ekrandaki onayı bekleyin` | The session was committed. Not done yet: watch for the platform's own toast, or a confirmation dialog to accept. |
| `Kurulum reddedildi: imza uyuşmuyor` | Genuinely the wrong key — rebuild and re-sign with `platform.pk8`. Unlike the old catch-all, this one means what it says. |
| `Kurulum reddedildi: APK okunamadı` | The archive does not parse, or it is built for another package. |
| `Kurulum başlatılamadı … Log sayfasına bakın` | The session could not be created or committed. `adb logcat -s EVABRP.Update`. |
| `Kurulum başarısız: INSTALL_FAILED_…` | From `OtaInstallResultReceiver` — the package manager's own words for why it refused. |
| `Güncelleme kontrolü başarısız` | The check threw. `adb logcat -s EVABRP.Update OtaUpdater` if a cable is available; otherwise the in-app Log page. |
| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. |
| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. |
| `Update check failed` | The server could not be reached, or answered with an error: 404 when updates are off on the server (no `FORGE_TOKEN`), 502 when the forge refused the server's token. |
| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. |
| `Download or SHA-256 verification failed (…)` | The download broke off, or the bytes do not match the published hash. |
| `Update refused: the downloaded file is not this app` | The archive does not parse, or it is another package. |
| `Installing 2.16.0… the app restarts when it is done` | The session was committed; the result follows as its own toast. |
| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. |
| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. |
## How the install works, and why it is not `pm install`
On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`.
`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit
that fails every time, and the reason is worth writing down because the symptom pointed
somewhere else entirely.
## Why a PackageInstaller session, not `pm install`
The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
— it passes the path to the package manager service, which opens it from *its* process and
SELinux context, where an app's private cache is not readable. The install failed, and
because `install()` returned a bare boolean, the caller reported the only failure it knew
how to name: `Kurulum reddedildi (imza uyuşmuyor?)`. A correctly signed build, refused with
a signature error it had not earned. The `pm` output that would have said so was captured
and thrown away.
It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it
into a session, so no second process ever has to read our cache. The approach is taken from
Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private
cache. That fails on this head unit: `pm` hands the path to the package manager service,
which opens it from its own process and SELinux context, where an app's private cache is not
readable — and the failure surfaced as a signature error it never was. A session takes no
path: the app opens the archive itself and streams it in. The approach comes from
[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
the same wall on the same hardware.
the same wall on the same hardware. Upstream later fell back to "download to Downloads and
tap it"; this fork tries the session first and keeps that as the fallback.
Two consequences for anyone reading a failure:
- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button
can only report that an install *started*; the verdict arrives at
`OtaInstallResultReceiver` and appears as its own toast.
- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to
confirm. The receiver launches the confirmation activity the platform hands it.
Still unverified on the car: whether this build installs silently or raises that
confirmation. Either way it installs, which the old path did not.
## Why installation is manual
`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and
shows a notification toast when a complete APK + hash pair exists. It never downloads or
installs. The refresh button is the only trigger for download, verification and installation.
The upload service does **not** check for updates on its tick. Installing a new APK
restarts the process, and doing that mid-drive would interrupt the telemetry the service
exists to send.
**Not yet tried on this car:** whether the session installs silently as the system user, or
asks for a confirmation.
+161 -185
View File
@@ -1,261 +1,237 @@
# FatihsMG4
# Nils' MG4
<p align="center"><img src="docs/logo.svg" width="440" alt="FatihsMG4"></p>
An Android Automotive app for the **MG4 (SAIC eh32)** head unit, plus a small server: the car
sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2
for the Grafana dashboards, and its live data straight to
[A Better Route Planner](https://abetterrouteplanner.com).
[![Release](https://img.shields.io/github/v/release/fatihdonmezdev/FatihsMG4?include_prereleases&sort=semver)](https://github.com/fatihdonmezdev/FatihsMG4/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
No OBD dongle and no phone in the loop: the app runs on the head unit and reads the car's own
APIs.
Sends live telemetry from an **MG4 (SAIC eh32)** to
[A Better Route Planner](https://abetterrouteplanner.com) — battery level, speed, range,
temperature, charging state and position — straight from the car's own APIs.
No OBD dongle. No Home Assistant. No phone in the loop. The app runs on the car's head
unit and talks to ABRP directly.
- Repository: https://git.stinnesbeck.com/nils/mg4
- Fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), itself built on malys' EVABRPUploader and Leon Kernan's `ABRP_Uploader`.
- What changed and what is still to do: [`SPECS.md`](SPECS.md).
> ⚠️ **No warranty, no liability.** This software is provided "as is" and runs on a
> **vehicle**. Installing it is your decision and your risk — see
> **vehicle**. Installing it is your decision and your risk; see
> [`DISCLAIMER.md`](DISCLAIMER.md). Not affiliated with SAIC, MG Motor or Iternio.
> MG and MG4 are third-party marks used only to identify compatibility; no official origin
> or approval is claimed.
> MG and MG4 are third-party marks used only to identify compatibility.
> **Fork notice.** This is a fork of Leon Kernan's `ABRP_Uploader`, substantially
> reworked. **Read [`LICENSE.md`](LICENSE.md) before redistributing anything** — the
> upstream project carries no licence, which limits what may legally be published.
> **Licence.** MIT, like Leon Kernan's original `ABRP_Uploader` and the forks in between —
> except the bundled `EVHardware/` library, which is PolyForm Noncommercial, so the built app
> may only be used and shared non-commercially. See [`LICENSE.md`](LICENSE.md).
---
## Contents
- [Screenshots](#screenshots)
- [Overview](#overview)
- [Install](#install)
- [Configuration](#configuration)
- [Screenshot](#screenshot)
- [How it fits together](#how-it-fits-together)
- [The app](#the-app)
- [Install on the car](#install-on-the-car)
- [Setup](#setup)
- [Building](#building)
- [Server and dashboards](#server-and-dashboards)
- [Project layout](#project-layout)
- [Project documents](#project-documents)
- [Security](#security)
- [Contributing](#contributing)
- [Legal](#legal)
- [Credits](#credits)
## Screenshots
## Screenshot
Version 2.2.6 introduces an OLED-black interface with charcoal cards, muted gold and
cyan accents, a vehicle overview, and responsive side or bottom navigation. This
app-specific theme intentionally overrides the shared EVSuite visual tokens.
Tablet UI previews at 1280×720; vehicle readings are unavailable on this device.
Version 2.8.2 at the head unit's 1920x720 pixels and 160 dpi, in full screen (Android 9 emulator; no
vehicle there, so every value shows as a dash).
<p align="center">
<img src="screenshots/evabrp-2.2.6-overview.png" width="760" alt="OLED vehicle overview with energy and tyre panels above compact metrics">
<img src="screenshots/evabrp-2.2.6-abrp.png" width="760" alt="Dark ABRP account configuration">
<img src="screenshots/nilsmg4-2.8.2-overview.png" width="900" alt="Overview tab: battery, range, range estimates, power and speed tiles above the energy and tyre-pressure cards">
</p>
---
## How it fits together
## Overview
| Signal | Source | Firmwares |
|---|---|---|
| State of charge | `EVHardware` vendor charging service, with a SWI68 property fallback | all supported (fallback SWI68 only) |
| Range | `EVHardware` vendor charging service, standard AAOS property, then SWI68 fallback | all supported |
| Speed, parked state | `EVHardware` shared energy snapshot | all supported |
| Outside temperature | The head unit's weather broadcast, falling back to its map-service query; the vehicle sensor first where one exists | all supported (the vehicle sensor is absent on MG4) |
| Charging, DC fast charging | Charge port state + charge rate heuristic | where the VHAL implements them |
| Charged energy this session | `EVHardware` charging-session integration | where the charge rate is readable |
| State of energy, pack capacity, pack temperature | `EVHardware` typed standard-AAOS reads | where the VHAL implements them |
| Odometer, tyre pressures | Standard AAOS properties (privileged) | platform-signed installs only |
| Cabin temperature | `EVHardware` typed HVAC read | where the VHAL implements it |
| Climate setpoint | `EVHardware` shared energy snapshot | all supported |
| Position, elevation, heading | GPS | — |
```
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
│ ──daily chunks─────▶ │
│ ──charging sessions▶ ▼
│ InfluxDB 2 ◀── Grafana
└──live telemetry─────▶ ABRP
```
All vehicle telemetry goes through **EVHardware**, which detects the generation
(SWI68/69/131/132/133/165), owns property ids and units, and applies the shared nullable
fallback order. `CarPropertyAdapter` remains only as transport for the debug-only VHAL probe
and is never instantiated by the production service. See [`FIRMWARE.md`](FIRMWARE.md).
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
ABRP and the server do not depend on each other. The server only stores. The car authenticates
with the server using a token of its own, obtained by pairing with a phone (below).
Odometer and tyre pressures sit behind privileged AAOS permissions: a platform-signed
install sends them, any other install simply omits them.
## The app
A property the car will not give up is **omitted** from the payload, never sent as zero —
so ABRP is never told the battery is empty because a read failed. Position is held to the
same rule in time as well as in value: a GPS fix older than `max(5 min, 3 x your upload
interval)` is dropped rather than resent, so a stale fix cannot report the car back at the
place it set off from.
| Tab | What it shows |
|---|---|
| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, energy since start, power and speed with their 5-minute averages; odometer, climate setpoint and tyre pressures |
| **Consumption** | Daily and period consumption, efficiency |
| **Charging** | Charging sessions and curves, cost in euro |
| **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 (usable capacity, manual SoH), charging price, version, update check, About |
| **Logs** | Upload log |
Telemetry only reads the car. Version 2.2.5 removes window automation entirely; there
is no window service, door listener or window command path in the app.
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
voice key** on the steering wheel and the app comes to the front (switch on the Settings
tab, on by default).
- **Full screen:** hides SAIC's top and bottom bars while the app is open; a swipe from the
edge shows them briefly (Settings tab, on by default).
- **Live telemetry:** every 15 s while driving or charging, much less often while parked. A
value the car cannot read is left out, never sent as zero, so ABRP is never told the battery
is empty because a read failed.
- **Read-only:** the app never writes a vehicle setting.
## Install
The MG4 head unit hides Settings and APK install. The known route in:
The MG4 head unit has no visible way to open Settings or install an APK. The known route
in (via the on-screen keyboard) is:
Signals the MG4 does not publish (battery, cabin and outside temperature, pack energy and
capacity) are listed in [`SPECS.md`](SPECS.md#signals-the-car-does-not-provide).
1. Open any app with a text field and tap it to raise the on-screen keyboard — e.g. the
Amazon Music app's email/login field.
2. **Long-press** the comma `,` (or the `@`) key on the keyboard.
3. Tap **"Language settings"**.
4. Tap the **search** icon in the top bar and type **`backup`**. It opens an empty page —
now press the **back** arrow, and you land in Android's Settings panel.
5. Enable **Developer options**, and turn on **"Install unknown apps"** (unknown sources).
6. In Settings, search **`storage`** — you now have access to internal storage and the USB
key. Navigate to the APK and tap it to install.
## Install on the car
> ⚠️ You are enabling developer options and sideloading on a car. Do this **parked**, and
> only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md).
The car build (`NilsMG4-unstable-<version>.apk`) runs with the system UID and is signed with the
AOSP test platform key that the MG4 head unit uses. It installs next to the old FatihsMG4 app
(package `com.stinnesbeck.nilsmg4.unstable`), not over it.
Two channels. Pick one — they install side by side.
Without adb, through the head unit's hidden file manager:
| Channel | Auto-update | Use it if |
|---|---|---|
| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it |
| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land |
1. Copy the APK to a USB stick and plug it in.
2. Open **Bluetooth Settings** and select the car name.
3. Long-press the **comma** key until the keyboard settings appear.
4. **Android Keyboard Settings → Languages**, then search for `file` in the box at the top.
5. That opens the Files app: open the USB stick, tap the APK and install.
Grab the APK from [Releases](https://github.com/fatihdonmezdev/FatihsMG4/releases). Stable builds are the tagged ones; unstable builds
are marked pre-release.
With adb:
### Getting the APK onto the car
```sh
adb push build/car/NilsMG4-unstable-<version>.apk /data/local/tmp/update.apk
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
```
The MG4 has no visible file manager. To reach it:
> ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md).
1. Open **Bluetooth Settings** and select the car name.
2. Long-press the **comma** key until the keyboard settings appear.
3. **Android Keyboard Settings → Languages**.
4. Search for `file` in the search box at the top.
5. That gives you the Files app.
After that first install, the app updates itself: publish a build with `publish-car`, then
press the refresh button in the app's top bar (`OTA.md`). The car fetches it through your car
server, so the repository stays private.
From there, open your USB stick and tap the APK.
## Setup
### First run
1. Open the app (voice key double press, or "Open" right after installing).
2. On the **Server** tab the URL is already `https://car.k8s.stinnesbeck.com`.
3. Tap **Pair with phone**. Scan the QR code with a phone (or open the address shown and enter
the code), and approve with the server's approval password. The car receives its token.
4. On the **Live data** tab, turn live data on. The service starts with the car from then on.
5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP.
1. Open the app.
2. By default, the app prefills the open-source telemetry API key published by the
[SAIC Python MQTT Gateway](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway#abrp-api-integration).
You can replace it with your own key. Get a token from your ABRP account.
3. Paste the token, press **Test** (read-only — it does not send telemetry), then **Save**.
4. Turn the switch on. The service restarts with the car from then on.
### On your phone
Typing a long API key and token on the car's on-screen keyboard is painful. Instead you can
put them in a text file and tap **Import file** — see [Config file](#config-file) below.
## Configuration
| Setting | Default | Notes |
|---|---|---|
| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving |
| More often at low battery | On | Tightens to 15 s below the threshold |
| Low battery threshold | 20 % | Editable, 1–99 |
| Service on/off | Off | Also the "stop uploading" control |
Parked and unplugged, the app drops to **one upload every 15 minutes** on its own,
whatever the setting. A state change — parked, plugged in, unplugged — always uploads
immediately.
Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a
QR code. It opens a form on your server where you type the ABRP API key and user token, the
charging price and the usable capacity, and send them with the approval password. The car
collects them within a few seconds and saves them through the same checks as the fields on
screen. The server only passes them on: they are held in memory until the car has them (at most
ten minutes), never stored or logged.
### Config file
To avoid typing on the head unit, put your settings in a plain text file and tap **Import file**
on the **ABRP** tab. The app fills in whatever the file contains. Nothing is sent — press
**Test**, then **Save** as usual.
The head unit has no file picker, so the file is not chosen by hand: copy it from a PC into
Instead of typing, put the settings in a text file and tap **Import file** on the Server tab.
The head unit has no file picker, so copy the file to
```
Android/data/com.evsuite.abrp/files/
Android/data/com.stinnesbeck.nilsmg4.unstable/files/
```
on the USB stick (or on the head unit's internal storage), then tap **Import file**. The app
scans that folder on every mounted volume and takes the first file that parses — the name does
not matter. If nothing is found it shows the exact path to copy to. On a phone or tablet, where
a document picker exists, the button falls back to letting you pick any file.
The file is a `key = value` list, one per line. Blank lines and lines starting with `#` are
ignored, keys are case-insensitive, and any key you leave out keeps its current value:
on the USB stick or the internal storage. The app scans that folder on every mounted volume and
takes the first file that parses. Format, one `key = value` per line, `#` for comments; keys left
out keep their current value:
```
# ABRP Uploader config
api_key = your-abrp-api-key
token = your-abrp-user-token
# optional — the cadence controls, same values as the UI
interval_sec = 60 # snapped to 15 / 30 / 60 / 120 / 300
server_url = https://car.k8s.stinnesbeck.com
token = the server's INGEST_TOKEN (not needed after pairing)
installation_id = only to carry over history from an install older than 2.9.0
interval_sec = 60 # 15 / 30 / 60 / 120 / 300
boost_low_soc = true
low_soc_percent = 20 # clamped to 1–99
low_soc_percent = 20
abrp_api_key = your ABRP API key
abrp_token = your ABRP user token
charging_price = 0.39 # €/kWh
usable_kwh = 50.8 # usable battery capacity, for the range estimates
```
Only `api_key` and `token` are usually needed. The file is read once on import and not kept —
your credentials live only in the app's encrypted store afterwards.
After a successful import the app deletes the file, since it holds the token; credentials live
only in the app's encrypted store.
The installation ID is derived from the head unit, so a reinstall finds its history again on its
own. `installation_id` is only needed to carry history over from an install made before 2.9.0.
A paired token belongs to one installation ID: after changing the ID, pair again.
### Power
The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping
head unit; it costs nothing while the car is off. While the car is on, both the upload tick
and the GPS subscription cost power, and the GPS interval follows your configured
frequency.
At defaults that is roughly **59 uploads/hour driving** and **3/hour parked**.
The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping head
unit and costs nothing while the car is off.
## Building
Requires JDK 17 and the Android SDK. With [mise](https://mise.jdev):
```bash
mise install # JDK
mise run bootstrap # Android SDK + local.properties
mise run check # permission gate + lint + tests
mise run build # debug APK
With Nix:
```sh
nix develop # JDK 21, Android SDK, Go
./gradlew assembleStableDebug # debug APK for a phone or tablet
build-car # car APK, signed with the AOSP test platform key
```
Without mise: set `JAVA_HOME` to a JDK 17, write `sdk.dir` into `local.properties`, then
`./gradlew assembleStableDebug`.
`build-car` writes `build/car/NilsMG4-unstable-<version>.apk` and a `.sha256` next to it, and
checks the signing certificate before it finishes.
Release builds are signed from environment variables. These sign the **APK** and have
nothing to do with your ABRP API key, which you type into the app itself:
Tests and lint:
| Variable | What it is |
|---|---|
| `SIGNING_KEYSTORE` | Path to the keystore file (CI decodes `SIGNING_KEYSTORE_BASE64` into one) |
| `SIGNING_STORE_PASSWORD` | Opens the keystore container |
| `SIGNING_KEY_ALIAS` | Which key inside it to sign with (default `platform`) |
| `SIGNING_KEY_PASSWORD` | Opens that particular key — a keystore protects each key separately |
```sh
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
```
**Never commit a keystore or a password.** Put them in `mise.local.toml`, which is
gitignored, or in GitHub Actions secrets.
[`mise`](https://mise.jdx.dev) tasks from upstream (`mise run check`, `mise run build`) still
work for the plain Gradle builds.
## Server and dashboards
- **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption
chunks, charging sessions, pairing. Image at
`git.stinnesbeck.com/nils/mg4-server`.
- **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and
Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored
`k8s/secrets/secrets.yaml`, from the template next to it.
- **Grafana dashboards** (`k8s/grafana/dashboards/`): Live, Consumption and Charging, provisioned
with the InfluxDB datasource.
## Project layout
```
app/src/main upload service, ABRP payload and UI
EVHardware shared vehicle reads, energy snapshot and integration
app/src/stable no-op update hook — the stable channel cannot self-update
app/src/unstable audited updater policy; runtime trigger currently suspended
app/src/debug VHAL probe tools, absent from every release build
app/src/main app: upload service, server client, pairing, UI
app/src/stable no-op update hook (no updater in the APK)
app/src/unstable car build: system UID, updater (currently pointed nowhere)
app/src/debug VHAL probe tools, absent from release builds
app/src/test JVM unit tests
EVHardware shared vehicle reads per firmware generation
server/ Go server
k8s/ Kubernetes manifests and Grafana provisioning
flake.nix dev shell and build-car
```
## Project documents
| Document | What it covers |
|---|---|
| [DESIGN.md](DESIGN.md) | The EVSuite design system — colour, type, touch targets, icons |
| [SPECS.md](SPECS.md) | Journal of this fork: architecture, status, to-dos, done log |
| [FIRMWARE.md](FIRMWARE.md) | MG4 firmware generations and how the app reads each |
| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository |
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change |
| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure |
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing |
| [CHANGELOG.md](CHANGELOG.md) | Release history |
| [LICENSE.md](LICENSE.md) | Licence text |
## Security
See [SECURITY.md](SECURITY.md) for the threat model and how to report a vulnerability
privately.
## Contributing
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Short version: this code runs on a moving
vehicle, so changes need tests and a clear account of what you verified on a car and what
you did not.
## Legal
- [`DISCLAIMER.md`](DISCLAIMER.md) — no warranty, no liability, not affiliated with the
carmaker or with ABRP.
- [`LICENSE.md`](LICENSE.md) — unresolved licence status inherited from the fork source.
Read it before redistributing.
- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability privately.
| [DESIGN.md](DESIGN.md) | The EVSuite design system (the app overrides its palette) |
| [SECURITY.md](SECURITY.md) | Threat model |
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer; read before installing |
| [CHANGELOG.md](CHANGELOG.md) | Upstream release history |
| [LICENSE.md](LICENSE.md) | Licences: MIT, and PolyForm Noncommercial for `EVHardware/` |
## Credits
- Original app: **Leon Kernan** — the car-API approach and the first working uploader.
- **fatihdonmezdev**: [FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), the app this fork
starts from.
- **malys**: EVABRPUploader.
- **Leon Kernan**: the original `ABRP_Uploader`, the car-API approach and the first working
uploader.
- ABRP telemetry API: [Iternio](https://documenter.getpostman.com/view/7396339/SWTK5a8w).
- Sibling project: **EVProfile**, whose security and CI patterns this repo reuses.
+298
View File
@@ -0,0 +1,298 @@
# SPECS — Nils' MG4
Journal for this fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4):
what has been changed, and what is still to do.
- Our repository: https://git.stinnesbeck.com/nils/mg4
- Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader
and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog.
## Architecture: car server at car.k8s.stinnesbeck.com
The car sends everything to our own server, which stores it in InfluxDB 2, and its live data
straight to ABRP with the ABRP credentials kept (encrypted) in the app. The server does not talk
to ABRP. Grafana reads InfluxDB directly for the dashboards.
```
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
│ ──daily chunks─────▶ │
│ ──charging sessions▶ ▼
│ InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com)
└──live telemetry─────▶ ABRP api.iternio.com/1/tlm/send
```
- **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration
and the storage layout. Replaced the Next.js app from upstream (`web/`, removed 2026-10-17).
- **Dashboards:** Grafana, provisioned from `k8s/grafana/`, reading with a read-only token: Live
(stats, SoC/range, power/speed, route map, temperatures, tyres), Consumption (period totals, lifetime, SoH, per-day energy,
distance and efficiency) and Charging (totals, session table, curves). Prices in euro.
- **Pairing:** the car shows a QR code, a phone approves it with the approval password, and the car
gets its own token, bound to its installation id (RFC 8628-style; `server/README.md`). The
shared `INGEST_TOKEN` still works. The same QR flow carries settings typed on the phone.
- **Car app:** tabs Overview, Consumption, Charging, Live data (on/off, ABRP credentials,
frequency), Server (URL, pairing, token, installation id, config import), Settings (display,
voice key, tyre unit, accent colour, battery, charging price, Wi-Fi), Logs. Live telemetry every
15 s while driving or charging, to the server and straight to ABRP; consumption chunks and
charging sessions to the server.
- **Deployment:** `k8s/`, plain manifests. **Nothing is ever applied to the cluster by us** —
Nils applies them (`k8s/README.md`). TLS comes from Traefik's default certificate.
### Status
Current: app 2.14.2, server 2.5.0 (pinned by digest in `k8s/server.yaml`).
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
without black bands.
- [x] Go server: tests and govulncheck clean; end to end in podman against InfluxDB 2.7.12
(telemetry, consumption, charging, pairing, settings from the phone) and Grafana 13.2.3 (all
dashboard queries return data).
- [x] Manifests validated offline (kubeconform, with Traefik CRDs). **Applied by Nils only.**
- [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`).
- [x] Screen on the car: 1920×720 px at 160 dpi, app window 1778×720 px (Logs → SCREEN).
- [x] Car-colour probe on the car: only `ro.build.mtk2712.vehicle_type = AS28`; the paint colour
is not in the system properties. It may be in the VHAL (a debug-build VhalProbe listing
would tell); until then the accent is chosen by hand.
- [ ] Not yet tried on the car: live data straight to ABRP (2.11), Enter on phone (2.12), accent
colours (2.14), right-aligned values (2.14.1).
### Signals the car does not provide
Checked 2026-10-17 against what actually reached InfluxDB (`schema.measurementFieldKeys`). The car
sends soc, est_battery_range, odometer, power, voltage, current, speed, hvac_setpoint, the four
tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does **not** send:
- **Battery temperature:** read from `EV_BATTERY_AVERAGE_TEMPERATURE`, an Android 14 property; the
head unit runs Android 9, and the SAIC services offer none.
- **Cabin temperature:** `HVAC_TEMPERATURE_CURRENT` is not published; only the setpoint is.
- **Outside temperature:** not published by the vehicle (upstream noted the same). Upstream used
the head unit's weather service as a stand-in (`HeadUnitWeather`, removed in `ea8032a`);
restoring it was considered and declined.
- **Pack energy (`soe`) and capacity:** not published. The Live dashboard computes energy in pack
as SOC × usable capacity, from the hidden dashboard constant `usableKwh` (50.8 kWh for the
51 kWh battery) in `k8s/grafana/dashboards/live.json`.
## To do
- [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users,
sessions, preferences, provisioned-dashboard records, alerting state) in SQLite at
`/var/lib/grafana/grafana.db` on the `grafana-data` PVC. That is fine for one replica, and is
why `k8s/grafana.yaml` uses `replicas: 1` and the `Recreate` strategy: only one Grafana may
write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL
(`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead
of a volume. The car data itself is in InfluxDB and unaffected.
- [ ] **EVHardware's comments are partly French.** About 120 comment lines in the vendored
`EVHardware/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The
app, the docs and the build files are English.
## Done
- 2026-10-17 — Translated the app UI from Turkish to English.
- 2026-10-17 — Nix flake: `nix develop` provides JDK 21 and the Android SDK. `build-car` builds the
unstable APK and signs it with the AOSP test platform key, which the MG4 head unit uses
(cert SHA-256 `c8a2e9bc…192ab8`).
- 2026-10-17 — OTA update checks disabled by pointing at an unresolvable host (see To do).
- 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car
build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing
it, and starts with empty settings and history.
- 2026-10-17 — Server rewritten for InfluxDB 2 with live telemetry and ABRP forwarding; Dockerfile;
Kubernetes manifests (not applied).
- 2026-10-17 — App 2.3.0: Server tab (URL, ingest token, installation id, config import), live
telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls
reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again
(AbrpApi and HeadUnitWeather tests deleted with their classes).
- 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4.
- 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the
dashboard and the car app's charging tab.
- 2026-10-17 — Server rewritten in Go (API only); dashboards moved to Grafana with provisioned
InfluxDB datasource and three dashboards; the Next.js app (`web/`) removed. Per-host
cert-manager certificates dropped in favour of Traefik's default certificate.
- 2026-10-17 — Pairing by QR code: server 2.1.0 issues per-car tokens after approval on a phone;
app 2.4.0 shows the QR code and collects its token. Tested end to end against InfluxDB.
- 2026-10-17 — Incident: a commit swept the filled-in `secrets.example.yaml` (InfluxDB admin token,
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
- 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules,
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-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest
`NilsMG4-unstable-<version>.apk` newer than the installed one in a stick's top folder, checked
against its `.sha256` and the package, installed with the OTA installer session. Tested on the
emulator: 2.18.0 installed a 2.18.1 from the storage's top folder silently, as the system user —
so the OTA install step should not need a confirmation either.
- 2026-10-18 — App 2.17.5: Server tab fits the car's window. The "Your private connection" card is
gone (its text was out of date); Import from file sits with Save and Test in one row; the
installation ID is shown as text, not edited, since it is derived from the head unit (a config
file can still carry another one over). The subtitle no longer says the server forwards to ABRP.
- 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Vehicle info" (2.17.4; "Odometer and climate" in 2.17.3):
it only holds those two, since the car publishes no pack energy or temperatures.
- 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and
CHANGE_WIFI_STATE). Upstream used it to keep the head unit on a phone hotspot; this car's head
unit has its own connection.
- 2026-10-18 — App 2.17.1: Settings without the Wi-Fi and saved-networks cards, in three columns
(display and steering wheel | battery | charging price and app). Logs keeps only the upload log: the steering-key
reports, the car-colour probe and the screen line are gone, their questions answered.
- 2026-10-18 — App 2.17.0: "Speed, 5-min average" card under Speed. The top bar is one compact line
(title and subtitle); version, update check and About moved to an App card on the Settings tab.
The Overview fits the car's window without scrolling: checked on the emulator at 1778×640, the
layout height the car reports (less page padding, a lower tyre card).
- 2026-10-18 — App 2.16.4: drop-downs again, as Material exposed dropdown menus: their list leaves
the focus with the activity, so full screen stays (a Spinner's list is a focusable window of its
own without the full-screen flags). 2.16.3 had replaced them with buttons.
- 2026-10-18 — App 2.16.3: full screen stays while choosing or reading a dialog. Drop-downs (upload
frequency, tyre unit, accent colour) became buttons on the page: a drop-down is a window of its
own without the full-screen flags, so the car showed its bars while it was open. Dialogs are
shown unfocusable, given the activity's full-screen flags, then allowed focus
(`showKeepingFullScreen`); none of them takes typing.
- 2026-10-18 — From the car (2.16.2): screen 1920×720 px at 160 dpi with a 1778×720 px app
window, so EVProfile's 1280×480 does not apply; colour probe found only `vehicle_type = AS28`.
FIRMWARE.md, mise.toml and SPECS.md say so.
- 2026-10-18 — App 2.16.2: Logs → SCREEN shows the panel's pixels and density and the app
window in px and dp. The MG4's display figures disagree (1920×720 px used here; EVProfile lists
1280×480, which is 1920×720 at 240 dpi in dp; upstream reported a 1778×720 window); FIRMWARE.md
lists all three until a photo of that line settles it.
- 2026-10-18 — EVProfile's licence checked: MIT, forked from SliDeeN/MG4Control and last merged
from it on 2026-08-02, inside MG4Control's MIT period (MIT up to 2.6.5 of 2026-08-05, GPL v3
from 2.6.6 of 2026-09-02). Recorded in `LICENSE.md`. History scanned for secrets before the
repository went public: none.
- 2026-10-18 — Licence: Leon Kernan's `ABRP_Uploader` has been MIT since 2026-07-29 (its commit
`75d8f2bc`), so the "unresolved" notes are gone. `LICENSE` names every copyright holder in the
fork chain; `LICENSE.md` explains that `EVHardware/` is PolyForm Noncommercial, which makes the
built APK non-commercial. README, AGENTS.md and CONTRIBUTING.md updated.
- 2026-10-18 — App 2.16.1: the Overview reads the car every 0.5 s (was 2 s), on a background
thread; the consumption and charging trackers stay on the 2-second tick (they integrate over
time, and the charging tracker writes its session to storage on every sample). Note for
emulator tests: uiautomator can no longer dump the screen while the Overview is open, since
the UI is never idle.
- 2026-10-18 — Grafana's read-only InfluxDB token without a manual step: an init container in
the Grafana pod creates it with the admin token at every start, deletes the previous one and
leaves only the read-only token in a memory volume; the datasource reads it with
`$__file{/run/influx/token}`. InfluxDB ignores chosen token values (tested), so it cannot come
from the Secret. The `grafana-influx` Secret is gone. Tested in podman: InfluxDB 2.7.12, the
script as the Grafana user (twice: rotates, no pile-up), Grafana 13.2.3 datasource healthy.
- 2026-10-18 — App 2.16.0, server 2.6.0: over-the-air updates from this repository. `publish-car`
creates the pre-release `car-<version>` on the private forge with the APK and its `.sha256`;
the server relays releases and attachments to the car (`/v1/app/…`, read with a read-only
`FORGE_TOKEN`); the app checks them through its server with its own token, allows exactly the
server's host, downloads itself (no DownloadManager, so the token stays in the app), verifies
SHA-256 and package, and tries a PackageInstaller session before falling back to Downloads.
`OTA.md` rewritten for this flow. Not yet tried on the car.
- 2026-10-18 — App 2.15.0: unsent live samples survive a restart. The queue is saved to
`files/telemetry_queue.json` (AtomicFile) at most every 5 minutes and only after a change, and
at once when the service stops; it is restored, in order and within its 2000-sample cap, when
the service starts. A killed process loses at most 5 minutes. Tested on the emulator across a
reboot (6 queued samples back).
- 2026-10-18 — App 2.14.4: every UI text is in `strings.xml` (Charging, Consumption and Settings
labels, toasts, the trip-reset dialog, charging and Wi-Fi states, the daily summary, the "—"
placeholder); lint reports no hard-coded text. Upload-log entries stay in code as log text.
- 2026-10-18 — Translated the rest: `mise.toml`, the comments in `app/build.gradle`, `OTA.md`'s
message table (now the updater's current English messages), the Turkish messages quoted in
`CHANGELOG.md` and `OTA.md` (glossed), two test messages, the ignition-state comment in
`EVHardware.kt`.
- 2026-10-18 — About dialog: link and QR code point to https://git.stinnesbeck.com/nils/mg4
(were Fatih's GitHub). Status section brought up to date.
- 2026-10-18 — App 2.14.2: "Range, 5-min average", like the usage and power cards (was "Range at 5-min average").
- 2026-10-18 — App 2.14.1: Overview values and their notes right-aligned, with tabular
(equal-width) digits, so 9 → 10 → 100 no longer shifts the text; labels stay left.
- 2026-10-18 — App 2.14.0: accent colour selectable after the MG4 paints (Settings → Display):
Dynamic Red (default), Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument Silver,
Arctic White, Black Pearl; white, silver and black become light greys and a pearl blue-grey,
since they cannot be an accent on black. The accent palette is theme attributes
(`attrs_accent.xml`, overlays in `accents.xml`); a change recreates the activity on the same
page. Android 9 crashes on gradients with theme colours, so the battery card is layered.
Logs tab: "Look for the car colour" lists system properties that mention colour, paint or car
model, to find out whether the paint colour can be read (nothing on the emulator).
- 2026-10-18 — App 2.13.0: tyre pressures in bar by default, kPa or psi selectable (Settings →
Display; display only, the car and the server keep kPa). "Power, 5-min average" card under
Power: time-weighted mean battery power, standing included.
- 2026-10-18 — App 2.12.1: no scroll bars (the 2-second refresh kept them from ever fading);
"Energy since start" tile (net kWh of the trip, recuperation counted back) left of "Usage since
start".
- 2026-10-18 — App 2.12.0, server 2.5.0: settings entered on a phone. "Enter on phone" shows a QR
code; the phone form on the server (approval password) takes the ABRP credentials, charging
price and usable capacity, and the car collects them once (RFC 8628-style, like pairing; values
in server memory only, at most 10 minutes). The config file also takes `charging_price` and
`usable_kwh`. The QR dialog is shared with pairing (`PhoneQrDialog`). Tested end to end with
curl against the server image and InfluxDB; the app side on the emulator only up to the dialog.
- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the
server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no
longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working.
- 2026-10-18 — App 2.11.0, server 2.3.0:
- Live data goes to ABRP straight from the car when ABRP credentials are set in the app
(Live data tab or config file: `abrp_api_key`, `abrp_token`), so ABRP keeps getting it
while the server is down. The car tells the server the newest sample ABRP took
(`abrpSentUtc`); the server forwards only newer ones. Without credentials in the app the
server forwards as before. This deliberately puts ABRP credentials in the car again
(encrypted, never logged).
- Settings regrouped: Live data (on/off, ABRP, frequency), Server, Settings (full screen, voice
key, usable capacity and SoH from Consumption, charging price from Charging, Wi-Fi, which
lost its own tab). Charging and Live data have their own icons.
- 2026-10-18 — App 2.10.2: the range shows "∞" instead of "> 999 km" while recuperating.
- 2026-10-18 — App 2.10.1: net regeneration (recuperating downhill) shows as negative usage, and
the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving".
- 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses
the consumption since the car was switched on, including standing with the climate on, and a
new trip starts after 10 minutes without samples or when charging. Usage (kWh/100 km) for both
estimates moved into cards of their own under the range tiles, which fit on the full-screen
1920x720 overview without scrolling.
- 2026-10-18 — Code review fixes. App 2.9.0, server 2.2.0, Grafana token:
- The server drops a bad value (or a sample without a plausible time) instead of rejecting
the batch, and answers 200 with what it dropped; the app sends only values within the
server's ranges (speed as a magnitude) and no samples before its clock is set. Before, one
reversing sample cost the whole queued batch.
- Pairing: the token is handed out and accepted only after the device is saved; wrong-password
lockouts and open-request limits are per client address; a paired token only serves its
own installation id (403 otherwise).
- ABRP forwarding skips samples stamped ahead of the server's clock, which used to block it.
- App: settings written in plaintext during a keystore failure move back into encrypted
storage; the installation id is derived from ANDROID_ID, so a reinstall keeps its history;
the imported config file is deleted; GPS stops when live telemetry is off and pauses while
parked and idle; range estimates use the monotonic clock; one HTTP helper (`ServerHttp`)
for all server calls; the last Turkish strings translated.
- Grafana reads InfluxDB with its own read-only token (`grafana-influx` Secret, created by
hand: `k8s/README.md`).
- 2026-10-18 — README rewritten for Nils' MG4. `OTA.md`, `mise.toml` and
`docs/MG4_PLATFORM_INSTALL.md` use the new package ID and `NilsMG4-*.apk` names; `OTA.md` notes
that updates are switched off. `CHANGELOG.md` keeps the old ID as upstream history.
- 2026-10-18 — App 2.8.2: full screen uses the whole display. The MG4 hid its bars but left a black
band where they were: LAYOUT_STABLE kept reporting the bars' insets and the fitsSystemWindows root
padded for them. Both are off in full screen now.
- 2026-10-17 — App 2.8.1: three-part version names (`2.8.1-unstable`, `NilsMG4-unstable-2.8.1.apk`).
Upstream's fourth part was its CI build number; the version code keeps the x100000 factor so it
never falls below codes already installed.
- 2026-10-17 — App 2.8.0: immersive full screen (switch in the Upload tab, default on). Hides the
system bars on the emulator; whether SAIC's own bars on the MG4 can be hidden is only known once
tried on the car.
- 2026-10-17 — App 2.7.1: the voice key works. The car's key report, read off the on-screen log:
`android.intent.extra.hardkey.keycode` (Integer), `.down` (Boolean, a report per press and per
release), `.longpress` (Boolean). 2.7.0 looked only for short names and never found the code.
Floating button removed (it did not show over the MG4 launcher).
- 2026-10-17 — App 2.7.0: a double press of the steering wheel's voice key (code 287) opens the
app, from the head unit's `com.saic.keyevent.hardkey.report` broadcast. Its extras are not
documented; the usual names are tried and every report is listed raw on the Logs tab. The
floating button (2.6.0) did not show over the MG4 launcher. Pages no longer jump back to the
top on every refresh (`StableScrollView`), and the Vehicle tab shows all six value tiles,
including both range estimates, in one row at the top.
- 2026-10-17 — App 2.6.0: floating button to open the app, because the MG4 launcher does not list
it. Drawn over other apps (needs the overlay permission, which the car build has through the
system UID), draggable, hidden while the app is open, switch in the Upload tab. Tested on an
Android 9 AOSP emulator with the car APK (installs as UID 1000 there too), at 1920x1080 and at the
head unit's real 1920x720 / 160 dpi, where all tabs and the button were checked as well.
- 2026-10-17 — App 2.5.0: range estimates at current and 5-minute average power (Vehicle tab).
- 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last
update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and
current replaces the Temperatures chart.
- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
## Installing on the car
```
nix develop
build-car
adb push build/car/NilsMG4-unstable-<version>.apk /data/local/tmp/update.apk
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
```
To remove the old upstream app: `adb uninstall com.evsuite.abrp.unstable`.
+38 -44
View File
@@ -10,14 +10,14 @@ android {
}
}
// Signature de l'APK — rien à voir avec la clé d'API ABRP de l'utilisateur.
// Ces valeurs servent UNIQUEMENT au build : elles prouvent que l'APK vient bien de
// nous et n'entrent jamais dans l'application. La clé d'API et le token ABRP, eux,
// sont saisis dans l'app et stockés chiffrés sur l'appareil (voir SecurePrefs).
// APK signing — nothing to do with the user's ABRP credentials. These values are ONLY for
// the build: they prove the APK comes from us and never enter the app. The ABRP API key and
// token are entered in the app and stored encrypted on the device (see SecurePrefs).
// The car build is signed by build-car (flake.nix) with the AOSP platform key instead.
//
// Un keystore contient plusieurs clés : SIGNING_STORE_PASSWORD ouvre le conteneur,
// SIGNING_KEY_ALIAS choisit la clé, SIGNING_KEY_PASSWORD ouvre cette clé-là.
// Lues depuis l'environnement (CI) ou gradle.properties local — JAMAIS commitées.
// A keystore holds several keys: SIGNING_STORE_PASSWORD opens the container,
// SIGNING_KEY_ALIAS picks the key, SIGNING_KEY_PASSWORD opens that key.
// Read from the environment (CI) or a local gradle.properties — NEVER committed.
def keystorePath = System.getenv("SIGNING_KEYSTORE") ?: project.findProperty("signing.keystore")
signingConfigs {
if (keystorePath != null && file(keystorePath).exists()) {
@@ -31,33 +31,27 @@ android {
}
defaultConfig {
applicationId "com.evsuite.abrp"
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 56
versionName "2.2.39"
versionCode 95
versionName "2.18.0"
// MongoDB credentials never ship in the APK. The app only talks to the small
// authenticated API in server/, which owns the database connection.
buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\""
buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\""
// Fixed installation identity: baked into every build so an APK wipe cannot orphan
// the cloud history under a new random UUID. The car's own consumption history lives
// under this id in the daily_consumption collection.
buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\""
// 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.
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// null quand aucun keystore n'est configuré : le build reste possible sur une
// machine sans secrets (APK non signé), au lieu d'échouer.
// null when no keystore is configured: the build still works on a machine without
// secrets (unsigned APK) instead of failing.
def platformSigning = signingConfigs.findByName("platform")
// Deux canaux de distribution :
// - stable : diffusé en release taguée, AUCUN auto-update. L'utilisateur installe
// lui-même ; rien dans l'app ne va chercher un APK.
// - unstable : pré-releases publiées à chaque push sur master, avec OTA pour que les testeurs
// soient à jour sans manipulation. Installable à côté du stable.
// Two distribution channels:
// - stable: no self-update at all. The user installs it; nothing in the app fetches an
// APK (the updater is not even in this APK).
// - unstable: the car build (system UID, platform-signed), with the OTA updater, so the
// car can update itself from published pre-releases. Installs next to stable.
flavorDimensions += "channel"
productFlavors {
stable {
@@ -67,16 +61,13 @@ android {
unstable {
dimension "channel"
applicationIdSuffix ".unstable"
// Android uses versionCode, not versionName, to order package updates. Give
// every rolling CI build a strictly increasing code so Package Installer does
// not treat a newer unstable APK as the same package version.
versionCode defaultConfig.versionCode * 100000 +
(project.findProperty('unstableBuild') ?: '0').toString().toInteger()
// Le numéro de build fait partie du COEUR numérique de la version
// ("1.0.42-unstable"), pas du suffixe : l'updater compare des segments
// numériques, donc deux builds unstable doivent différer avant le premier tiret.
// -PunstableBuild=<n> est passé par la CI ; 0 en local.
versionName "${defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}"
// Three-part versions, the same as defaultConfig ("2.8.1-unstable"). Upstream added
// a fourth part for its CI build number; there is no CI here and the OTA updater is
// off. The version code keeps upstream's x100000 so it never drops below codes
// already installed on the car (2.8.0.0 was 6600000): a lower code is a downgrade,
// which the installer refuses.
versionCode defaultConfig.versionCode * 100000
versionName defaultConfig.versionName
versionNameSuffix "-unstable"
buildConfigField "boolean", "OTA_ENABLED", "true"
}
@@ -112,25 +103,28 @@ android {
}
}
// Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être
// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la
// pré-release est la constante "unstable", écrasée à chaque build.
// The version build-car puts in the APK's file name ("NilsMG4-unstable-2.8.1.apk"), which
// is also the name the OTA updater reads a release's version from.
tasks.register('printUnstableVersion') {
doLast {
println "${android.defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}"
println android.defaultConfig.versionName
}
}
// Nomme les APK par canal pour que les artefacts de release soient lisibles.
// Names the APKs by channel so the build outputs are readable.
androidComponents {
onVariants(selector().all()) { variant ->
variant.outputs.each { output ->
if (variant.buildType == "release") {
// Le canal unstable garde un nom de fichier stable : l'updater identifie
// la version par le tag de la pré-release, jamais par le nom d'APK.
// A fixed name for the unstable release output. What is published for the car
// comes from build-car, named NilsMG4-unstable-<version>.apk: that file name is
// where the updater reads a release's version from.
output.outputFileName.set(variant.flavorName == "unstable"
? "FatihsMG4-unstable.apk"
: "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk")
? "NilsMG4-unstable.apk"
: "NilsMG4-${variant.flavorName}-${output.versionName.get()}.apk")
} else {
// Debug builds: NilsMG4-stable-debug.apk, NilsMG4-unstable-debug.apk.
output.outputFileName.set("NilsMG4-${variant.flavorName}-${variant.buildType}.apk")
}
}
}
+7 -7
View File
@@ -8,13 +8,12 @@
<!-- Required from API 33 to post the foreground-service notification, which is the
only status UI the service has. Without it the notification is silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- GPS for the position in live telemetry. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
reading which one it landed on. No password is ever handled here — see
WifiAutoConnect for why this works on this Android 9 unit and not on a newer one. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android Automotive / CarPropertyManager permissions -->
<uses-permission android:name="android.car.permission.CAR_SPEED" />
@@ -52,7 +51,8 @@
<service
android:name=".AbrpUploadService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="dataSync|location" />
<!-- The head unit resumes far more often than it cold-boots, and announces the
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* ABRP's telemetry API, for sending live data straight from the car.
*
* Credentials go in the POST body, never in the query string: a URL travels through proxies,
* access logs and crash reports, a body does not. Neither is ever logged.
*/
final class AbrpApi {
/** No query string: credentials travel in the body, so the URL is a constant. */
static final String SEND_URL = "https://api.iternio.com/1/tlm/send";
/**
* Read-only endpoint used to check credentials. The Test button must never touch
* /tlm/send: a placeholder sample would tell ABRP the car really is in that state and
* corrupt a live route plan.
*/
static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
private AbrpApi() { }
/** Posts one tlm sample. Throws on a network failure. */
static ServerHttp.Response send(AbrpConfig config, String tlmJson) throws Exception {
byte[] body = formBody(config.apiKey, config.userToken, tlmJson).getBytes(StandardCharsets.UTF_8);
return ServerHttp.send("POST", SEND_URL, null, "application/x-www-form-urlencoded", body);
}
/**
* Checks the credentials without writing anything. This endpoint only takes the token as a
* query parameter; the API key goes in the Authorization header. One token in one URL when
* the user presses Test beats writing fake telemetry on every press.
*/
static ServerHttp.Response verify(AbrpConfig config) throws Exception {
String url = VERIFY_URL + "?token=" + URLEncoder.encode(config.userToken, StandardCharsets.UTF_8.name());
return ServerHttp.send("GET", url, "APIKEY " + config.apiKey, null, null);
}
/** The form body: credentials and the sample, each URL-encoded. */
static String formBody(String apiKey, String userToken, String tlmJson) throws Exception {
String utf8 = StandardCharsets.UTF_8.name();
return "api_key=" + URLEncoder.encode(apiKey, utf8)
+ "&token=" + URLEncoder.encode(userToken, utf8)
+ "&tlm=" + URLEncoder.encode(tlmJson, utf8);
}
/** ABRP answers 200 with {"status":"ok"}; anything else is a rejection. */
static boolean accepted(ServerHttp.Response response) {
if (!response.ok()) return false;
try {
return "ok".equals(new org.json.JSONObject(response.body).optString("status"));
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,62 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
/**
* The ABRP credentials for sending live data straight from the car: the API key (which app is
* sending) and the user token (which car in which ABRP account). Kept in {@link SecurePrefs}
* next to the server settings, entered on the Live data tab or imported from a config file.
*
* The car is the only one that sends to ABRP: the server stores live data but does not forward
* it. Without these, ABRP gets no live data.
*/
final class AbrpConfig {
static final String KEY_API_KEY = "abrp_api_key";
static final String KEY_USER_TOKEN = "abrp_user_token";
final String apiKey;
final String userToken;
private static volatile AbrpConfig current;
AbrpConfig(String apiKey, String userToken) {
this.apiKey = apiKey;
this.userToken = userToken;
}
boolean isConfigured() {
return !apiKey.isEmpty() && !userToken.isEmpty();
}
static AbrpConfig get(Context context) {
AbrpConfig config = current;
if (config != null) return config;
synchronized (AbrpConfig.class) {
if (current == null) {
SharedPreferences prefs = SecurePrefs.get(context);
current = new AbrpConfig(prefs.getString(KEY_API_KEY, ""), prefs.getString(KEY_USER_TOKEN, ""));
}
return current;
}
}
/** Stores trimmed values; empty strings switch direct sending off. */
static synchronized void save(Context context, String apiKey, String userToken) {
String key = apiKey == null ? "" : apiKey.trim();
String token = userToken == null ? "" : userToken.trim();
SecurePrefs.get(context).edit()
.putString(KEY_API_KEY, key)
.putString(KEY_USER_TOKEN, token)
.apply();
current = new AbrpConfig(key, token);
}
/** Plausible as an ABRP credential: no spaces, not absurdly short or long. */
static boolean isPlausible(String value) {
if (value == null) return false;
String v = value.trim();
return v.length() >= 8 && v.length() <= 200 && !v.contains(" ");
}
}
@@ -24,11 +24,13 @@ import java.util.concurrent.TimeUnit;
/**
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything to ABRP.
* syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that
* the server stores and, with ABRP credentials set, that go straight to ABRP
* ({@link TelemetryUploader}).
*
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
* credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
* missing one is handled by caching chunks locally until the connection returns.
* credentials or a toggle. The only thing it needs is a network, and a missing one is
* handled by queueing locally until the connection returns.
*/
public class AbrpUploadService extends Service {
@@ -64,7 +66,8 @@ public class AbrpUploadService extends Service {
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
private WifiAutoConnect wifi;
private TelemetryUploader telemetryUploader;
private SteeringKeyListener steeringKeys;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
@@ -93,14 +96,16 @@ public class AbrpUploadService extends Service {
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
telemetryUploader = new TelemetryUploader(getApplicationContext());
steeringKeys = new SteeringKeyListener(this, prefs);
steeringKeys.register();
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
"Tüketim servisi başlatıldı"));
"Consumption service started"));
Log.i(TAG, "Service started, consumption tracking armed");
}
@@ -111,15 +116,16 @@ public class AbrpUploadService extends Service {
}
/**
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
* service types do not exist before API 29, so on this API-28 head unit a plain
* startForeground is the only correct call.
* Enters the foreground. Foreground service types do not exist before API 29, so on this
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
* location type is what lets the GPS subscription run in the background.
*/
private void startInForeground() {
Notification notification = buildNotification(lastStatus);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
this, NOTIF_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC | ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
} else {
startForeground(NOTIF_ID, notification);
}
@@ -133,6 +139,11 @@ public class AbrpUploadService extends Service {
running = false;
prefs.edit().putBoolean("service_running", false).apply();
if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) {
telemetryUploader.persist(); // what is still queued survives the restart
telemetryUploader.stop();
}
if (steeringKeys != null) steeringKeys.unregister();
Log.i(TAG, "Service stopped");
}
@@ -149,17 +160,27 @@ public class AbrpUploadService extends Service {
private void doUpload() {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
// Keeps the 5-minute range average going while the app is in the background.
RangeEstimator.shared().add(android.os.SystemClock.elapsedRealtime(),
vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
// exactly when being offline costs something. Rate-limited internally.
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
wifi.ensureConnected(false);
UploadSettings settings = UploadSettings.from(prefs);
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
// nowhere to send it.
if (!ServerConfig.get(this).isConfigured() && !AbrpConfig.get(this).isConfigured()) {
telemetryUploader.stop(); // no live telemetry, so no GPS either
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
telemetryUploader.stop();
updateNotification(getString(R.string.notif_tracking_locally));
} else {
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
int queued = telemetryUploader.queued();
updateNotification(queued > 0 ? "Sending telemetry — " + queued + " queued" : "Sending telemetry");
}
updateNotification("Tüketim takibi aktif");
}
// ---------- Notification ----------
@@ -0,0 +1,51 @@
package com.evsuite.abrp;
import android.content.Context;
import android.util.TypedValue;
/**
* The app's accent colour, after the MG4 paint colours; Dynamic Red unless another is chosen
* (Settings → Display). Each is a theme overlay in res/values/accents.xml that MainActivity lays
* over the app theme before it inflates anything; a change recreates the activity.
*/
enum AccentColor {
DYNAMIC_RED("Dynamic Red", R.style.ThemeOverlay_EV_Accent_DynamicRed),
VOLCANO_ORANGE("Volcano Orange", R.style.ThemeOverlay_EV_Accent_VolcanoOrange),
HOLBORN_BLUE("Holborn Blue", R.style.ThemeOverlay_EV_Accent_HolbornBlue),
RACING_GREEN("Racing Green", R.style.ThemeOverlay_EV_Accent_RacingGreen),
CAMDEN_GREY("Camden Grey", R.style.ThemeOverlay_EV_Accent_CamdenGrey),
MONUMENT_SILVER("Monument Silver", R.style.ThemeOverlay_EV_Accent_MonumentSilver),
ARCTIC_WHITE("Arctic White", R.style.ThemeOverlay_EV_Accent_ArcticWhite),
BLACK_PEARL("Black Pearl", R.style.ThemeOverlay_EV_Accent_BlackPearl);
static final String PREF_KEY = "accent_color";
static final AccentColor DEFAULT = DYNAMIC_RED;
final String label;
final int overlay;
AccentColor(String label, int overlay) {
this.label = label;
this.overlay = overlay;
}
/** The stored choice; anything unknown falls back to Dynamic Red. */
static AccentColor fromPref(String name) {
for (AccentColor c : values()) {
if (c.name().equals(name)) return c;
}
return DEFAULT;
}
/** A colour of the current accent palette, e.g. {@code R.attr.evAccent}, for code that draws. */
static int resolve(Context context, int attr) {
TypedValue value = new TypedValue();
context.getTheme().resolveAttribute(attr, value, true);
return value.data;
}
/** The colour with its alpha replaced, for glows and grids drawn in code. */
static int withAlpha(int color, int alpha) {
return (color & 0x00FFFFFF) | (alpha << 24);
}
}
@@ -14,7 +14,7 @@ public final class ChargingGraphView extends View {
private final Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1);
private final List<float[]> points = new ArrayList<>();
private final float[] socPower = new float[101];
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x5548C7D9); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(AccentColor.withAlpha(AccentColor.resolve(c, R.attr.evAccentLight), 0x55)); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
synchronized void add(Float soc, Float power) {
if (soc == null || power == null || power >= 0) return;
float p = Math.abs(power);
@@ -33,13 +33,13 @@ public final class ChargingGraphView extends View {
invalidate();
}
synchronized String tableText() {
StringBuilder out = new StringBuilder("SOC Şarj gücü\n");
StringBuilder out = new StringBuilder("SOC Charge power\n");
boolean found = false;
for (int soc=0; soc<=100; soc++) if (!Float.isNaN(socPower[soc])) {
found = true;
out.append(String.format(Locale.getDefault(), "%3d %% %6.1f kW\n", soc, socPower[soc]));
}
return found ? out.toString().trim() : "Henüz şarj ölçümü yok";
return found ? out.toString().trim() : "No charging measurements yet";
}
@Override protected synchronized void onDraw(Canvas c) {
super.onDraw(c); float left=58, right=getWidth()-12, top=12, bottom=getHeight()-38;
@@ -0,0 +1,171 @@
package com.evsuite.abrp;
/**
* Parses a configuration text file so the app can be set up without typing on the car's
* on-screen keyboard — the ingest token in particular is long and error-prone to enter by
* hand. The file is a plain {@code key = value} list, one entry per line:
*
* <pre>
* # Nils' MG4 config
* server_url = https://car.k8s.stinnesbeck.com
* token = 0123abcd... (the server's INGEST_TOKEN)
* installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b
* interval_sec = 60
* boost_low_soc = true
* low_soc_percent = 20
* abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly)
* abrp_token = your-abrp-user-token
* charging_price = 0.39 (€/kWh)
* usable_kwh = 50.8 (usable battery capacity, for the range estimates)
* </pre>
*
* Usually only server_url and token are needed. installation_id is for a reinstall: it puts
* the car back under the id its history is stored with. The cadence keys are optional. Blank lines and
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
* leaving anything absent untouched.
*/
final class ConfigImport {
final String serverUrl;
final String token;
final String installationId;
final Integer intervalSec;
final Boolean boostLowSoc;
final Integer lowSocPercent;
final String abrpApiKey;
final String abrpToken;
final Double chargingPrice;
final Double usableKwh;
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken,
Double chargingPrice, Double usableKwh) {
this.serverUrl = serverUrl;
this.token = token;
this.installationId = installationId;
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
this.abrpApiKey = abrpApiKey;
this.abrpToken = abrpToken;
this.chargingPrice = chargingPrice;
this.usableKwh = usableKwh;
}
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
boolean isEmpty() {
return serverUrl == null && token == null && installationId == null && intervalSec == null
&& boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null
&& chargingPrice == null && usableKwh == null;
}
static ConfigImport parse(String text) {
String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null;
Integer intervalSec = null, lowSocPercent = null;
Boolean boostLowSoc = null;
Double chargingPrice = null, usableKwh = null;
for (String line : text.split("\n", -1)) {
line = line.trim();
if (line.isEmpty() || line.charAt(0) == '#') continue;
int sep = line.indexOf('=');
if (sep <= 0) continue;
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
String value = line.substring(sep + 1).trim();
if (value.isEmpty()) continue;
switch (key) {
case "server_url":
case "server":
case "url":
serverUrl = value;
break;
case "token":
case "ingest_token":
token = value;
break;
case "installation_id":
installationId = value;
break;
case "interval_sec":
case "upload_interval_sec":
intervalSec = nearestInterval(value);
break;
case "boost_low_soc":
boostLowSoc = parseBool(value);
break;
case "low_soc_percent":
lowSocPercent = clampPercent(value);
break;
case "abrp_api_key":
abrpApiKey = value;
break;
case "abrp_token":
case "abrp_user_token":
abrpToken = value;
break;
case "charging_price":
case "price_per_kwh":
chargingPrice = parseDecimal(value);
break;
case "usable_kwh":
case "usable_capacity_kwh":
usableKwh = parseDecimal(value);
break;
default:
// Unknown keys are ignored rather than rejected, so a file can carry
// comments or future keys without breaking an older build.
break;
}
}
return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent,
abrpApiKey, abrpToken, chargingPrice, usableKwh);
}
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
private static Integer nearestInterval(String value) {
int requested;
try {
requested = Integer.parseInt(value);
} catch (NumberFormatException e) {
return null;
}
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
}
return best;
}
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
private static Integer clampPercent(String value) {
try {
return Math.max(1, Math.min(99, Integer.parseInt(value)));
} catch (NumberFormatException e) {
return null;
}
}
/**
* A decimal with a dot or a comma; null when it is not a number. Ranges are checked where
* the value is saved, the same as for the fields on screen.
*/
private static Double parseDecimal(String value) {
try {
double d = Double.parseDouble(value.replace(',', '.'));
return Double.isFinite(d) ? d : null;
} catch (NumberFormatException e) {
return null;
}
}
private static Boolean parseBool(String value) {
value = value.toLowerCase(java.util.Locale.US);
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
return null;
}
}
@@ -7,39 +7,31 @@ import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
* Best-effort sync to the car server. Never blocks local persistence.
*
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
* picks up right where the old one left off instead of showing zeroes.
* Server URL, token and installation id come from {@link ServerConfig}, read on every use so
* a change in the Server tab applies at once. The installation id is kept across updates, and
* a reinstall derives the same one from the head unit ({@link ServerConfig#installationIdFor})
* or gets it back through the config file, so the history is not orphaned under a new
* identity. The consumption UI reads back its own uploaded totals via
* {@link #fetchHistoryIfDue}, so a reinstall picks up where the old one left off.
*/
final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo";
private static final String TAG = "NilsMG4.Mongo";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L;
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint;
private final String token;
private final String installationId;
private final Context context;
private final SharedPreferences prefs;
private long lastConsumptionAttemptMs;
private long lastChargeAttemptMs;
@@ -65,21 +57,19 @@ final class ConsumptionCloudClient {
}
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
this.context = context.getApplicationContext();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
if (endpoint.isEmpty() || token.isEmpty()) {
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
}
}
private ServerConfig config() {
return ServerConfig.get(context);
}
/**
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
* to send (offline, server error) accumulate in a local cache and are retried on the
* next cycle — so a drive through a dead-signal area still reaches the cloud once the
* head unit is back on the hotspot. Every attempt is logged to the in-app "Kayıtlar" tab.
* head unit is back on the hotspot. Every attempt is logged to the in-app "Logs" tab.
*
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
* monotonic clock instead, so a backwards NTP correction cannot stall
@@ -87,7 +77,7 @@ final class ConsumptionCloudClient {
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime();
if (endpoint.isEmpty() || token.isEmpty()) {
if (!config().isConfigured()) {
Log.i(TAG, "Sync skipped: API not configured");
return;
}
@@ -96,7 +86,7 @@ final class ConsumptionCloudClient {
// Drain any cached chunks first — a backlog from an offline period has priority.
int cached = drainChunkCache();
if (cached > 0) {
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
logEvent(true, 200, "Cache drained: " + cached + " chunk(s) sent");
invalidateHistory();
}
@@ -115,14 +105,14 @@ final class ConsumptionCloudClient {
try {
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+"
logEvent(true, 200, "Chunk " + chunk.date + " sent (+"
+ String.format("%.1f", chunk.day.km) + "km, +"
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
invalidateHistory();
} else {
cacheChunk(body);
int pending = pendingChunkCount();
logEvent(false, 0, "Chunk gönderilemedi — cache'de bekliyor (" + pending + " chunk)");
logEvent(false, 0, "Chunk not sent — waiting in cache (" + pending + " chunk(s))");
}
} catch (Exception e) {
// Network failure: cache the chunk for the next cycle.
@@ -131,7 +121,7 @@ final class ConsumptionCloudClient {
cacheChunk(body);
} catch (Exception ignored) { }
int pending = pendingChunkCount();
logEvent(false, 0, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage());
logEvent(false, 0, "Offline — waiting in cache (" + pending + " chunk(s)): " + e.getMessage());
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
}
}
@@ -190,7 +180,7 @@ final class ConsumptionCloudClient {
return loadChunkCache().length();
}
/** Records a cloud-sync attempt to the in-app log (the "Kayıtlar" tab). */
/** Records a cloud-sync attempt to the in-app log (the "Logs" tab). */
private void logEvent(boolean success, int httpStatus, String detail) {
UploadLog log = AbrpUploadService.log();
if (log == null) return;
@@ -200,7 +190,7 @@ final class ConsumptionCloudClient {
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
throws Exception {
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("installationId", config().installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day));
@@ -220,7 +210,7 @@ final class ConsumptionCloudClient {
try {
syncPendingCharges(charges, pending);
} catch (Exception e) {
logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage());
logEvent(false, 0, "Charging session sync error: " + e.getMessage());
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
}
}
@@ -230,15 +220,15 @@ final class ConsumptionCloudClient {
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
JSONObject body = new JSONObject(session.toString()).put("installationId", config().installationId);
String id = session.getString("sessionId");
Log.i(TAG, "Uploading charge session: " + id);
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
charges.markUploaded(id);
logEvent(true, 200, "Şarj session gönderildi: " + id);
logEvent(true, 200, "Charging session sent: " + id);
Log.i(TAG, "Successfully uploaded charge session: " + id);
} else {
logEvent(false, 0, "Şarj session gönderilemedi: " + id);
logEvent(false, 0, "Charging session not sent: " + id);
Log.w(TAG, "Failed to upload charge session: " + id);
return;
}
@@ -247,36 +237,20 @@ final class ConsumptionCloudClient {
}
private boolean send(String path, String method, JSONObject body) throws Exception {
HttpURLConnection connection = null;
String url = trimSlash(config().url) + path;
Log.i(TAG, "Sending " + method + " request to " + url);
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
String url = trimSlash(endpoint) + path;
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
Log.i(TAG, "Body: " + body.toString());
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status);
if (status < 200 || status >= 300) {
String errBody = readErrorBody(connection);
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
ServerHttp.Response response = ServerHttp.request(method, url, config().token, body);
Log.i(TAG, "Response: HTTP " + response.status);
if (!response.ok()) {
String err = response.excerpt();
Log.w(TAG, "Sync rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
return false;
}
InputStream response = connection.getInputStream();
if (response != null) try (InputStream ignored = response) { }
return true;
} catch (Exception e) {
Log.w(TAG, "Network error: " + e.getMessage());
throw e;
} finally {
if (connection != null) connection.disconnect();
}
}
@@ -295,7 +269,7 @@ final class ConsumptionCloudClient {
* Returns the current cache immediately; the fresh copy lands on the next poll.
*/
synchronized List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
if (!config().isConfigured()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
@@ -310,11 +284,11 @@ final class ConsumptionCloudClient {
List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh;
Log.i(TAG, "Fetched " + fresh.size() + " history records from cloud");
logEvent(true, 200, "History çekildi: " + fresh.size() + " gün");
logEvent(true, 200, "History fetched: " + fresh.size() + " day(s)");
} catch (Exception e) {
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
Log.w(TAG, "History fetch unavailable: " + msg);
logEvent(false, 0, "History fetch hatası: " + msg);
logEvent(false, 0, "History fetch error: " + msg);
}
}
@@ -325,11 +299,11 @@ final class ConsumptionCloudClient {
/**
* Immediately attempts to send every cached chunk and take a fresh one, ignoring the
* 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line
* 2-minute throttle. Called from the UI "Upload now" button. Returns a one-line
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış";
if (!config().isConfigured()) return "API not configured";
int cached = drainChunkCache();
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
int total = cached;
@@ -348,50 +322,39 @@ final class ConsumptionCloudClient {
}
int pending = pendingChunkCount();
String msg = total > 0
? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "")
: (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok");
logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg);
? total + " chunk(s) uploaded" + (pending > 0 ? ", " + pending + " still pending" : "")
: (pending > 0 ? pending + " chunk(s) could not be uploaded (internet?)" : "No new data to upload");
logEvent(total > 0, total > 0 ? 200 : 0, "Manual sync: " + msg);
return msg;
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token);
int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {
String err = readErrorBody(connection);
Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory;
}
String body = readBody(connection);
JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
} finally {
if (connection != null) connection.disconnect();
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
ServerHttp.Response response = ServerHttp.request("GET", url, config().token, null);
Log.i(TAG, "History GET response: HTTP " + response.status);
if (!response.ok()) {
String err = response.excerpt();
Log.w(TAG, "History fetch rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, response.status, "History GET HTTP " + response.status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory;
}
JSONObject json = new JSONObject(response.body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
}
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
@@ -400,35 +363,6 @@ final class ConsumptionCloudClient {
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
}
private static String readBody(HttpURLConnection connection) throws Exception {
InputStream stream = connection.getInputStream();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
return sb.toString();
}
}
/** Reads the error response body (truncated) for logging — never throws. */
private static String readErrorBody(HttpURLConnection connection) {
try {
InputStream stream = connection.getErrorStream();
if (stream == null) return "";
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
String body = sb.toString();
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
}
} catch (Exception e) {
return "";
}
}
private static String trimSlash(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--;
@@ -216,7 +216,7 @@ final class ConsumptionTracker {
if (cloudLifetime.km > local.km) {
lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc);
persist();
Log.i("FatihsMG4.Consumption", "Lifetime baseline synced from cloud: "
Log.i("NilsMG4.Consumption", "Lifetime baseline synced from cloud: "
+ cloudLifetime.km + " km (was " + local.km + " km)");
}
}
@@ -0,0 +1,46 @@
package com.evsuite.abrp;
/**
* Spots a double press of one steering-wheel key from the head unit's key broadcasts.
*
* The broadcast format is not documented, so this copes with all the shapes it could take:
* - no up/down information at all: every broadcast is one press;
* - separate down and up broadcasts: only releases count. If the meaning of the action field
* is the other way round, downs count instead — still exactly one per press, so a double
* press is found either way;
* - a long press resets, so holding the key never opens anything.
*
* Android-free and unit-tested; the window is longer than the stock 300 ms, because a
* deliberate double press on a steering wheel is slower than one on a touch screen.
*/
final class DoublePressDetector {
static final long WINDOW_MS = 500;
private final int keyCode;
private long lastPressAt = Long.MIN_VALUE;
DoublePressDetector(int keyCode) {
this.keyCode = keyCode;
}
/**
* @param down true for a key-down, false for a release, null when the broadcast does not say
* @param longPress true when the broadcast marks a long press
* @return true exactly once per double press
*/
synchronized boolean accept(int code, Boolean down, boolean longPress, long atMillis) {
if (code != keyCode) return false;
if (longPress) {
lastPressAt = Long.MIN_VALUE;
return false;
}
if (Boolean.TRUE.equals(down)) return false;
if (lastPressAt != Long.MIN_VALUE && atMillis - lastPressAt <= WINDOW_MS) {
lastPressAt = Long.MIN_VALUE; // a third press starts over
return true;
}
lastPressAt = atMillis;
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
package com.evsuite.abrp;
import org.json.JSONObject;
/**
* The car's side of pairing with the server (an OAuth device-authorization flow): ask for a
* pairing request, show its code as a QR code, then poll until a phone has approved it and
* collect this car's own token. Blocking calls — run them off the main thread.
*/
final class PairingClient {
/** A pairing request as the server opened it. */
static final class Request {
final String deviceCode;
final String userCode;
final String verificationUri;
final String verificationUriComplete;
final long expiresAtMs;
final long intervalMs;
Request(String deviceCode, String userCode, String verificationUri, String verificationUriComplete,
long expiresAtMs, long intervalMs) {
this.deviceCode = deviceCode;
this.userCode = userCode;
this.verificationUri = verificationUri;
this.verificationUriComplete = verificationUriComplete;
this.expiresAtMs = expiresAtMs;
this.intervalMs = intervalMs;
}
}
enum Outcome { PENDING, APPROVED, DENIED, EXPIRED }
static final class PollResult {
final Outcome outcome;
final String token;
PollResult(Outcome outcome, String token) {
this.outcome = outcome;
this.token = token;
}
}
/** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
static final class NotEnabledException extends Exception { }
/** The server answered with a status the flow does not expect. */
static final class HttpStatusException extends java.io.IOException {
final int status;
HttpStatusException(int status) {
super("HTTP " + status);
this.status = status;
}
}
/** What the server says when APPROVAL_PASSWORD is unset; any other 404 is a missing route. */
static final String NOT_ENABLED_MESSAGE = "pairing is not enabled";
private PairingClient() { }
static Request start(String serverUrl, String installationId) throws Exception {
ServerHttp.Response r = post(serverUrl + "/v1/pair/start",
new JSONObject().put("installationId", installationId));
// Only the server's own answer means "switched off". A 404 without it is a server (or
// proxy) that has no pairing route at all, e.g. one older than 2.1.0.
if (r.status == 404 && r.body.contains(NOT_ENABLED_MESSAGE)) throw new NotEnabledException();
if (r.status != 201) throw new HttpStatusException(r.status);
JSONObject json = new JSONObject(r.body);
long now = System.currentTimeMillis();
return new Request(
json.getString("deviceCode"),
json.getString("userCode"),
json.getString("verificationUri"),
json.getString("verificationUriComplete"),
now + json.optLong("expiresIn", 600) * 1000L,
Math.max(2, json.optLong("interval", 5)) * 1000L);
}
static PollResult poll(String serverUrl, String deviceCode) throws Exception {
ServerHttp.Response r = post(serverUrl + "/v1/pair/poll", new JSONObject().put("deviceCode", deviceCode));
switch (r.status) {
case 200: return new PollResult(Outcome.APPROVED, new JSONObject(r.body).getString("token"));
case 202: return new PollResult(Outcome.PENDING, null);
case 403: return new PollResult(Outcome.DENIED, null);
case 410: return new PollResult(Outcome.EXPIRED, null);
default: throw new HttpStatusException(r.status);
}
}
private static ServerHttp.Response post(String url, JSONObject json) throws Exception {
return ServerHttp.request("POST", url, null, json);
}
}
@@ -0,0 +1,66 @@
package com.evsuite.abrp;
import org.json.JSONObject;
/**
* The car's side of entering settings on a phone: open a request on the server (with this
* car's token), show it as a QR code, then poll until the phone has sent the values, which
* the server hands out once. Same codes and QR flow as pairing ({@link PairingClient}).
* Blocking calls — run them off the main thread.
*/
final class PhoneSettingsClient {
/** What the phone sent; null fields were left empty there. */
static final class Settings {
final String abrpApiKey;
final String abrpToken;
final Double pricePerKwh;
final Double usableKwh;
Settings(String abrpApiKey, String abrpToken, Double pricePerKwh, Double usableKwh) {
this.abrpApiKey = abrpApiKey;
this.abrpToken = abrpToken;
this.pricePerKwh = pricePerKwh;
this.usableKwh = usableKwh;
}
static Settings parse(String body) throws Exception {
JSONObject json = new JSONObject(body);
return new Settings(text(json, "abrpApiKey"), text(json, "abrpToken"),
json.has("pricePerKwh") ? json.getDouble("pricePerKwh") : null,
json.has("usableKwh") ? json.getDouble("usableKwh") : null);
}
private static String text(JSONObject json, String key) {
String value = json.optString(key, "").trim();
return value.isEmpty() ? null : value;
}
}
private PhoneSettingsClient() { }
static PairingClient.Request start(ServerConfig config) throws Exception {
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/start", config.token,
new JSONObject().put("installationId", config.installationId));
if (r.status == 404) throw new PairingClient.NotEnabledException();
if (r.status != 201) throw new PairingClient.HttpStatusException(r.status);
JSONObject json = new JSONObject(r.body);
long now = System.currentTimeMillis();
return new PairingClient.Request(
json.getString("deviceCode"),
json.getString("userCode"),
json.getString("verificationUri"),
json.getString("verificationUriComplete"),
now + json.optLong("expiresIn", 600) * 1000L,
Math.max(2, json.optLong("interval", 5)) * 1000L);
}
/** Null while the phone has not sent anything; throws once the request has expired (410). */
static Settings poll(ServerConfig config, String deviceCode) throws Exception {
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/poll", config.token,
new JSONObject().put("deviceCode", deviceCode));
if (r.status == 202) return null;
if (r.status == 200) return Settings.parse(r.body);
throw new PairingClient.HttpStatusException(r.status);
}
}
@@ -8,7 +8,10 @@ import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */
/**
* Static, low-opacity light behind the glass panels, in the accent colour; no blur passes or
* animation loop.
*/
public final class PremiumBackdropView extends View {
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -20,10 +23,12 @@ public final class PremiumBackdropView extends View {
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
float radius = Math.max(1, Math.max(w, h) * 0.65f);
int deep = AccentColor.resolve(getContext(), R.attr.evAccentDeep);
int stroke = AccentColor.resolve(getContext(), R.attr.evAccentStroke);
cyan.setShader(new RadialGradient(w * 0.70f, 0, radius,
new int[]{0x2419565B, 0x00000000}, null, Shader.TileMode.CLAMP));
new int[]{AccentColor.withAlpha(deep, 0x48), 0x00000000}, null, Shader.TileMode.CLAMP));
gold.setShader(new RadialGradient(w, h, radius * 0.65f,
new int[]{0x1C8B693A, 0x00000000}, null, Shader.TileMode.CLAMP));
new int[]{AccentColor.withAlpha(stroke, 0x1C), 0x00000000}, null, Shader.TileMode.CLAMP));
}
@Override protected void onDraw(Canvas canvas) {
@@ -0,0 +1,40 @@
package com.evsuite.abrp;
import java.util.Locale;
/**
* The unit tyre pressures are shown in. The car reports kPa; this only changes the display.
* Android-free, unit-tested.
*/
enum PressureUnit {
BAR("bar", "%.1f bar", 0.01),
KPA("kPa", "%.0f kPa", 1.0),
PSI("psi", "%.1f psi", 0.145038);
static final String PREF_KEY = "tire_pressure_unit";
static final PressureUnit DEFAULT = BAR;
final String label;
private final String pattern;
private final double perKpa;
PressureUnit(String label, String pattern, double perKpa) {
this.label = label;
this.pattern = pattern;
this.perKpa = perKpa;
}
/** A reading in this unit, or a dash for none. */
String format(Float kpa) {
if (kpa == null || kpa.isNaN() || kpa.isInfinite()) return "—";
return String.format(Locale.US, pattern, kpa * perKpa);
}
/** The stored choice; anything unknown falls back to bar. */
static PressureUnit fromPref(String name) {
for (PressureUnit u : values()) {
if (u.name().equals(name)) return u;
}
return DEFAULT;
}
}
@@ -0,0 +1,206 @@
package com.evsuite.abrp;
import java.util.ArrayDeque;
import java.util.Iterator;
/**
* Estimated range from the energy left in the pack and how fast it is being used.
*
* since start: remaining kWh / (energy used / distance driven since the car was switched on)
* average: remaining kWh / (energy used / distance driven over the last 5 minutes)
*
* "Since start" is a trip like the car's own trip computer: it begins with the first sample
* after the head unit wakes (or after charging), so it includes standing with the climate on.
* A gap of {@link #TRIP_RESET_GAP_MS} without samples is the car having been off, and starts
* a new trip; so does charging.
*
* Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
* is a setting. An estimate is null when it cannot mean anything — not moved yet, too little
* data — rather than a misleading number. Net regeneration is shown as it is: negative usage.
*
* Fed by both the service tick (15 s) and the open Vehicle page (2 s); the integration is
* time-weighted, so the two rates mix correctly. Times are on the monotonic clock
* (SystemClock.elapsedRealtime): the head unit sets its wall clock from GPS, and a jump back
* would make every new sample look out of order. Free of Android types, unit-tested.
*/
final class RangeEstimator {
static final long WINDOW_MS = 5 * 60_000L;
/** Less than this much driving in the window is too little to average. */
static final long MIN_WINDOW_MS = 60_000L;
static final double MIN_DISTANCE_KM = 0.2;
/** A gap between samples longer than this (app paused, car off) is not integrated. */
static final long MAX_GAP_MS = 60_000L;
/** No sample for this long: the car was off, and the next sample starts a new trip. */
static final long TRIP_RESET_GAP_MS = 10 * 60_000L;
/** Standing still and taking in more than this: charging, which ends the trip. */
static final double CHARGING_KW = -1.0;
static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
private static final RangeEstimator SHARED = new RangeEstimator();
static RangeEstimator shared() { return SHARED; }
/** An estimate: the range, the consumption it is based on, and the distance behind it. */
static final class Estimate {
final double rangeKm;
final double kwhPer100Km;
final double distanceKm;
Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
this.rangeKm = rangeKm;
this.kwhPer100Km = kwhPer100Km;
this.distanceKm = distanceKm;
}
}
private static final class Sample {
final long timeMs;
final double powerKw;
final double speedKmh;
Sample(long timeMs, double powerKw, double speedKmh) {
this.timeMs = timeMs;
this.powerKw = powerKw;
this.speedKmh = speedKmh;
}
}
private final ArrayDeque<Sample> samples = new ArrayDeque<>();
// The trip since start: running totals, so it is not limited to the 5-minute window.
private double tripEnergyKwh;
private double tripDistanceKm;
private Sample tripLast;
/** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
Sample last = samples.peekLast();
if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick
Sample sample = new Sample(timeMs, powerKw, Math.max(0f, speedKmh));
addToTrip(sample);
samples.addLast(sample);
while (!samples.isEmpty() && samples.peekFirst().timeMs < timeMs - WINDOW_MS) samples.removeFirst();
}
static Double remainingKwh(Float socPercent, double usableKwh) {
if (socPercent == null || !Float.isFinite(socPercent) || usableKwh <= 0) return null;
return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
}
private void addToTrip(Sample s) {
boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
tripEnergyKwh = 0;
tripDistanceKm = 0;
tripLast = null;
}
if (charging) return; // the trip starts again after the charge
if (tripLast != null) {
long dt = s.timeMs - tripLast.timeMs;
if (dt > 0 && dt <= MAX_GAP_MS) {
double hours = dt / 3_600_000.0;
tripEnergyKwh += (tripLast.powerKw + s.powerKw) / 2 * hours;
tripDistanceKm += (tripLast.speedKmh + s.speedKmh) / 2 * hours;
}
}
tripLast = s;
}
/** Net energy taken from the pack since start, in kWh; null before the trip's first sample. */
synchronized Double energySinceStart() {
return tripLast == null ? null : tripEnergyKwh;
}
/** Range at the consumption since start; null until the trip has some distance behind it. */
synchronized Estimate sinceStart(Double remainingKwh) {
if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null;
return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm);
}
synchronized Estimate average(Double remainingKwh, long nowMs) {
if (remainingKwh == null) return null;
double energyKwh = 0, distanceKm = 0;
long covered = 0;
Sample previous = null;
for (Iterator<Sample> it = samples.iterator(); it.hasNext(); ) {
Sample s = it.next();
if (s.timeMs < nowMs - WINDOW_MS) continue;
if (previous != null) {
long dt = s.timeMs - previous.timeMs;
if (dt > 0 && dt <= MAX_GAP_MS) {
double hours = dt / 3_600_000.0;
energyKwh += (previous.powerKw + s.powerKw) / 2 * hours;
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * hours;
covered += dt;
}
}
previous = s;
}
if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM) return null;
return estimate(remainingKwh, energyKwh, distanceKm);
}
/**
* Net regeneration (downhill, recuperating) is a real reading: the usage is negative, and
* the range is unbounded rather than negative.
*/
private static Estimate estimate(double remainingKwh, double energyKwh, double distanceKm) {
double kwhPerKm = energyKwh / distanceKm;
double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY;
return new Estimate(rangeKm, kwhPerKm * 100, distanceKm);
}
/**
* Mean battery power over the last five minutes, time-weighted, standing included; null with
* less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it.
*/
synchronized Double averagePowerKw(long nowMs) {
double energyKwh = 0;
long covered = 0;
Sample previous = null;
for (Sample s : samples) {
if (s.timeMs < nowMs - WINDOW_MS) continue;
if (previous != null) {
long dt = s.timeMs - previous.timeMs;
if (dt > 0 && dt <= MAX_GAP_MS) {
energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0);
covered += dt;
}
}
previous = s;
}
if (covered < MIN_WINDOW_MS) return null;
return energyKwh / (covered / 3_600_000.0);
}
/** Mean speed over the last five minutes, time-weighted, standing included; null below a minute. */
synchronized Double averageSpeedKmh(long nowMs) {
double distanceKm = 0;
long covered = 0;
Sample previous = null;
for (Sample s : samples) {
if (s.timeMs < nowMs - WINDOW_MS) continue;
if (previous != null) {
long dt = s.timeMs - previous.timeMs;
if (dt > 0 && dt <= MAX_GAP_MS) {
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * (dt / 3_600_000.0);
covered += dt;
}
}
previous = s;
}
if (covered < MIN_WINDOW_MS) return null;
return distanceKm / (covered / 3_600_000.0);
}
synchronized void clear() {
samples.clear();
tripEnergyKwh = 0;
tripDistanceKm = 0;
tripLast = null;
}
}
@@ -0,0 +1,99 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import java.util.HashMap;
import java.util.Map;
/**
* Encrypted storage for the server settings — above all the ingest token, which `adb backup`
* would otherwise hand out with the regular SharedPreferences file. Backed by an
* AndroidKeyStore master key.
*
* If the keystore fails, the settings go to a plaintext file for that run rather than the app
* crashing on the head unit. The next run that opens the encrypted store moves them back in
* and deletes the file ({@link #mergePlaintext}), so a token saved meanwhile is neither lost
* nor left in plaintext.
*/
final class SecurePrefs {
private static final String TAG = "SecurePrefs";
private static final String FILE_NAME = "server_secure_prefs";
private static final String PLAIN_NAME = "server_prefs";
private static SharedPreferences instance;
private SecurePrefs() { }
/**
* Encrypted preferences, or a plaintext file if the crypto stack is unavailable. Falling
* back keeps the app usable on a head unit with a broken keystore instead of crashing it.
*/
static synchronized SharedPreferences get(Context context) {
if (instance != null) return instance;
Context app = context.getApplicationContext();
try {
MasterKey masterKey = new MasterKey.Builder(app)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
SharedPreferences encrypted = EncryptedSharedPreferences.create(
app,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
migrateFromPlaintext(app, encrypted);
instance = encrypted;
} catch (Exception e) {
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
}
return instance;
}
/** Moves what a fallback run wrote into the encrypted store, then deletes the plaintext. */
private static void migrateFromPlaintext(Context app, SharedPreferences encrypted) {
SharedPreferences plain = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
Map<String, ?> leftover = plain.getAll();
if (leftover.isEmpty()) return;
Map<String, String> moves = mergePlaintext(strings(leftover), strings(encrypted.getAll()));
SharedPreferences.Editor edit = encrypted.edit();
for (Map.Entry<String, String> e : moves.entrySet()) edit.putString(e.getKey(), e.getValue());
if (edit.commit()) {
plain.edit().clear().commit();
Log.i(TAG, "Moved " + moves.size() + " setting(s) from plaintext into encrypted storage");
}
}
/**
* What to write into the encrypted store from a fallback run's plaintext file. The plaintext
* is newer, so its URL and token win — that is a pairing done while the keystore was down.
* The installation id is the exception: the encrypted one is what the car's history is
* stored under, and a fallback run may only have made up a new one.
*/
static Map<String, String> mergePlaintext(Map<String, String> plain, Map<String, String> encrypted) {
Map<String, String> moves = new HashMap<>();
for (Map.Entry<String, String> e : plain.entrySet()) {
if (e.getValue() == null) continue;
String existing = encrypted.get(e.getKey());
if (ServerConfig.KEY_INSTALLATION_ID.equals(e.getKey()) && existing != null && !existing.isEmpty()) {
continue;
}
moves.put(e.getKey(), e.getValue());
}
return moves;
}
private static Map<String, String> strings(Map<String, ?> all) {
Map<String, String> out = new HashMap<>();
for (Map.Entry<String, ?> e : all.entrySet()) {
if (e.getValue() instanceof String) out.put(e.getKey(), (String) e.getValue());
}
return out;
}
}
@@ -0,0 +1,132 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Where the car's data goes: the server URL, its ingest token, and the installation id the
* data is stored under. Entered in the Server tab or imported from a config file, kept in
* {@link SecurePrefs}.
*
* Read through {@link #get}, which caches one immutable snapshot for the process and is
* replaced on {@link #save}: the service and the activity both see a change at once, without
* a restart.
*/
final class ServerConfig {
static final String KEY_URL = "server_url";
static final String KEY_TOKEN = "ingest_token";
static final String KEY_INSTALLATION_ID = "installation_id";
/** Same rule as the server: a version 1–5 UUID with the RFC 4122 variant. */
private static final Pattern UUID_PATTERN = Pattern.compile(
"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
Pattern.CASE_INSENSITIVE);
/** Pre-filled so pairing needs no typing in the car; can still be changed. */
static final String DEFAULT_URL = "https://car.k8s.stinnesbeck.com";
/** The server refuses shorter ingest tokens outright. */
static final int MIN_TOKEN_LENGTH = 24;
final String url;
final String token;
final String installationId;
private static volatile ServerConfig current;
ServerConfig(String url, String token, String installationId) {
this.url = url;
this.token = token;
this.installationId = installationId;
}
boolean isConfigured() {
return !url.isEmpty() && !token.isEmpty();
}
// ANDROID_ID on purpose: it is hashed into the installation id, so a reinstall keeps its
// history (installationIdFor). It is never sent or logged as it is.
@android.annotation.SuppressLint("HardwareIds")
static ServerConfig get(Context context) {
ServerConfig config = current;
if (config != null) return config;
synchronized (ServerConfig.class) {
if (current != null) return current;
SharedPreferences prefs = SecurePrefs.get(context);
String id = prefs.getString(KEY_INSTALLATION_ID, "");
if (!isValidInstallationId(id)) {
// First start, or the first after a reinstall: derived from the head unit, so
// the history stays under the same id (see installationIdFor).
String androidId = null;
try {
androidId = android.provider.Settings.Secure.getString(
context.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ANDROID_ID);
} catch (RuntimeException ignored) {
// no id from the platform: a random one below
}
id = installationIdFor(androidId);
if (id == null) id = UUID.randomUUID().toString();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id);
return current;
}
}
/** Stores already-validated values; see {@link #validate}. */
static synchronized void save(Context context, String url, String token, String installationId) {
SecurePrefs.get(context).edit()
.putString(KEY_URL, url)
.putString(KEY_TOKEN, token)
.putString(KEY_INSTALLATION_ID, installationId)
.apply();
current = new ServerConfig(url, token, installationId);
}
// ---------- Validation (pure, unit-tested) ----------
/**
* The URL as it will be used: trimmed, without a trailing slash. Null when it is not an
* https URL with a host — the token travels in a header, so plain http is refused.
*/
static String normalizeUrl(String raw) {
if (raw == null) return null;
String url = raw.trim();
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
if (!url.toLowerCase(Locale.US).startsWith("https://")) return null;
String rest = url.substring("https://".length());
if (rest.isEmpty() || rest.startsWith("/") || rest.contains(" ") || rest.contains("?")
|| rest.contains("#") || rest.contains("@")) return null;
return url;
}
static boolean isValidToken(String token) {
return token != null && token.trim().length() >= MIN_TOKEN_LENGTH && !token.trim().contains(" ");
}
/**
* The installation id for a head unit, or null without a usable ANDROID_ID. From Android 8
* ANDROID_ID is fixed per device and signing key and survives an uninstall, so a
* reinstall of the same platform-signed build finds its history again without importing
* the id. Hashed into a name-based UUID: the server expects a UUID, and the raw
* ANDROID_ID never leaves the car. An id already stored (or imported) always wins over it.
*/
static String installationIdFor(String androidId) {
if (androidId == null) return null;
String trimmed = androidId.trim();
// The emulator-era constant that many devices once shared; not an identity.
if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("9774d56d682e549c")) return null;
byte[] name = ("nilsmg4:" + trimmed.toLowerCase(Locale.US)).getBytes(java.nio.charset.StandardCharsets.UTF_8);
return UUID.nameUUIDFromBytes(name).toString();
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
}
@@ -0,0 +1,97 @@
package com.evsuite.abrp;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* One JSON request to the car server, shared by telemetry, consumption sync and pairing (and,
* through {@link #send}, the direct ABRP upload).
* Blocking, with short timeouts — call it off the main thread.
*
* The token goes in the Authorization header, never the URL, and is never logged. The answer
* body is read for every status (error bodies name the problem) and capped, so a wrong server
* cannot fill memory.
*/
final class ServerHttp {
static final int TIMEOUT_MS = 8_000;
private static final int MAX_BODY_BYTES = 64 * 1024;
/** Status and body of an answer. */
static final class Response {
final int status;
final String body;
Response(int status, String body) {
this.status = status;
this.body = body;
}
boolean ok() { return status >= 200 && status < 300; }
/** The body for a log line: short, on one line. */
String excerpt() {
String text = body.replace('\n', ' ').trim();
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
}
}
private ServerHttp() { }
/**
* Sends {@code json} (or nothing, when null) with {@code method} to {@code url}.
* {@code token} may be null for the unauthenticated pairing calls.
*/
static Response request(String method, String url, String token, JSONObject json) throws Exception {
byte[] body = json == null ? null : json.toString().getBytes(StandardCharsets.UTF_8);
return send(method, url, token == null ? null : "Bearer " + token, "application/json", body);
}
/**
* The request underneath, also used for ABRP ({@link AbrpApi}): {@code authorization} is the
* whole header value or null, {@code body} null for none.
*/
static Response send(String method, String url, String authorization, String contentType, byte[] body)
throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
try {
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
if (authorization != null) connection.setRequestProperty("Authorization", authorization);
if (body != null) {
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
connection.setRequestProperty("Content-Type", contentType);
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
}
int status = connection.getResponseCode();
return new Response(status, readBody(connection, status));
} finally {
connection.disconnect();
}
}
private static String readBody(HttpURLConnection connection, int status) {
try {
InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (in == null) return "";
try (InputStream stream = in) {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = stream.read(chunk)) != -1 && buf.size() < MAX_BODY_BYTES) buf.write(chunk, 0, n);
return buf.toString(StandardCharsets.UTF_8.name());
}
} catch (Exception e) {
// The status is what matters; an unreadable body is just empty.
return "";
}
}
}
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ScrollView;
/**
* A ScrollView that keeps the position the driver scrolled to.
*
* The pages live in ViewPager2's RecyclerView. When the live values change, the tiles ask for
* a new layout, and the RecyclerView lays the pages out once already while it is still
* measuring, with a provisional height at which the content seems to fit. ScrollView.onLayout
* then clamps the scroll position to 0, and the real layout that follows keeps it there: every
* refresh threw the page back to the top (seen in a stack trace on the emulator).
*
* Here only scrolling that is not part of a layout counts as the driver's position, and every
* layout restores it. ScrollView.scrollTo clamps against the real content height, so a
* position that no longer exists still ends at the bottom rather than past it.
*
* No scroll bar: a ScrollView shows its bar on every layout, and the live values lay the page
* out every two seconds, so on the car it never faded. The pages scroll by touch.
*/
public class StableScrollView extends ScrollView {
private int wantedScrollY;
private boolean inLayout;
public StableScrollView(Context context) {
super(context);
setVerticalScrollBarEnabled(false);
}
public StableScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
setVerticalScrollBarEnabled(false);
}
public StableScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setVerticalScrollBarEnabled(false);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (!inLayout) wantedScrollY = t;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
inLayout = true;
try {
super.onLayout(changed, l, t, r, b);
if (getScrollY() != wantedScrollY) scrollTo(getScrollX(), wantedScrollY);
} finally {
inLayout = false;
}
}
}
@@ -0,0 +1,104 @@
package com.evsuite.abrp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;
/**
* Opens the app on a double press of the steering wheel's voice key.
*
* The head unit reports steering-wheel keys as the broadcast {@link #ACTION}; code 287 is the
* voice key (see EVHardware's PhysicalButtonEventDecoder). The extras' format was read off the
* car (see {@link SteeringKeyReport}).
*
* Registered by the always-on service on the main thread; never throws into the head unit.
*/
final class SteeringKeyListener {
static final String ACTION = "com.saic.keyevent.hardkey.report";
static final int VOICE_KEY = 287;
private static final String TAG = "NilsMG4.SteeringKey";
private final Context context;
private final SharedPreferences prefs;
private final DoublePressDetector detector = new DoublePressDetector(VOICE_KEY);
private boolean registered;
private final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context c, Intent intent) {
try {
handle(intent);
} catch (RuntimeException e) {
Log.w(TAG, "Key broadcast not handled: " + e);
}
}
};
SteeringKeyListener(Context context, SharedPreferences prefs) {
this.context = context.getApplicationContext();
this.prefs = prefs;
}
void register() {
try {
// Exported on purpose: the report comes from the head unit's own key service, another
// app. The flag is required from API 33 and absent on this API-28 head unit;
// ContextCompat picks the right call for the platform underneath.
androidx.core.content.ContextCompat.registerReceiver(context, receiver, new IntentFilter(ACTION),
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
registered = true;
} catch (RuntimeException e) {
Log.w(TAG, "Key broadcasts not available: " + e);
}
}
void unregister() {
if (!registered) return;
try {
context.unregisterReceiver(receiver);
} catch (RuntimeException ignored) {
// already gone
}
registered = false;
}
private void handle(Intent intent) {
Bundle extras = intent.getExtras();
SteeringKeyReport key = read(extras);
boolean opens = false;
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime());
}
if (opens) {
try {
context.startActivity(new Intent(context, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
} catch (RuntimeException e) {
Log.w(TAG, "App not opened: " + e);
}
}
}
// ---------- Reading the broadcast ----------
/** An embedded KeyEvent if there is one, otherwise the extras by name (SteeringKeyReport). */
static SteeringKeyReport read(Bundle extras) {
if (extras == null) return SteeringKeyReport.parse(null);
java.util.Map<String, Object> values = new java.util.HashMap<>();
for (String k : extras.keySet()) {
Object v = extras.get(k);
if (v instanceof KeyEvent) {
KeyEvent e = (KeyEvent) v;
return new SteeringKeyReport(e.getKeyCode(), e.getAction() == KeyEvent.ACTION_DOWN, e.isLongPress());
}
values.put(k, v);
}
return SteeringKeyReport.parse(values);
}
}
@@ -0,0 +1,82 @@
package com.evsuite.abrp;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* Reads one steering-wheel key report from the broadcast's extras.
*
* Seen on the MG4 (2026-10-17, on-screen key log): the head unit sends
* android.intent.extra.hardkey.keycode = 287 (Integer)
* android.intent.extra.hardkey.down = true (Boolean, then false on release)
* android.intent.extra.hardkey.longpress = false (Boolean)
* Names are matched on their last dot-separated part, case-insensitively, so this exact form
* and simpler ones ("keyCode", "down", …) are both understood.
*
* Android-free (the extras arrive as a plain map), unit-tested.
*/
final class SteeringKeyReport {
final Integer code;
final Boolean down;
final boolean longPress;
SteeringKeyReport(Integer code, Boolean down, boolean longPress) {
this.code = code;
this.down = down;
this.longPress = longPress;
}
private static final String[] CODE_NAMES = {"keycode", "key_code", "keyvalue", "key_value", "code", "key"};
private static final String[] ACTION_NAMES = {"keyaction", "key_action", "action", "keystate", "key_state",
"state", "event", "type"};
private static final String[] DOWN_NAMES = {"down", "isdown", "pressed", "ispressed"};
private static final String[] LONG_NAMES = {"longpress", "islongpress", "long_press", "islong", "long"};
static SteeringKeyReport parse(Map<String, Object> extras) {
Map<String, Object> byName = new HashMap<>();
if (extras != null) {
for (Map.Entry<String, Object> e : extras.entrySet()) {
String key = e.getKey();
String last = key.substring(key.lastIndexOf('.') + 1).toLowerCase(Locale.US);
byName.putIfAbsent(last, e.getValue());
}
}
Integer code = firstInt(byName, CODE_NAMES);
Boolean down = null;
boolean longPress = false;
// KeyEvent's own convention for a numeric action: 0 down, 1 up; anything else is long.
Integer action = firstInt(byName, ACTION_NAMES);
if (action != null) {
if (action == 0) down = true;
else if (action == 1) down = false;
else longPress = true;
}
Boolean explicitDown = firstBoolean(byName, DOWN_NAMES);
if (explicitDown != null) down = explicitDown;
Boolean explicitLong = firstBoolean(byName, LONG_NAMES);
if (explicitLong != null) longPress = explicitLong;
return new SteeringKeyReport(code, down, longPress);
}
private static Integer firstInt(Map<String, Object> byName, String[] names) {
for (String n : names) {
Object v = byName.get(n);
if (v instanceof Integer) return (Integer) v;
if (v instanceof Long) return (int) (long) (Long) v;
if (v instanceof String) {
try { return Integer.parseInt(((String) v).trim()); } catch (NumberFormatException ignored) { }
}
}
return null;
}
private static Boolean firstBoolean(Map<String, Object> byName, String[] names) {
for (String n : names) {
Object v = byName.get(n);
if (v instanceof Boolean) return (Boolean) v;
}
return null;
}
}
@@ -0,0 +1,267 @@
package com.evsuite.abrp;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Builds one live telemetry sample in ABRP's tlm format. The car server stores it and forwards
* the newest one to ABRP, so the field names and units are exactly ABRP's.
*
* Kept free of Android types so it can be unit-tested on the JVM: the caller
* pulls the values out of the car adapter and the Location, this class only
* decides what ends up in the payload.
*
* Every vehicle field is nullable and a null field is OMITTED. A property that
* cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP
* the battery is empty and wrecks the user's route plan.
*
* Fields are set by name rather than passed positionally. The payload now carries
* 24 optional values and a positional build(...) call had become a wall of nulls
* that no reader could check against the ABRP field list.
*/
final class TelemetryPayload {
/** Only value that is always present. Epoch SECONDS, not milliseconds. */
private final long utc;
// --- High priority (ABRP: "many features will only be usable with enough data") ---
Integer soc; // %
Float speedKmh; // km/h
Float powerKw; // kW, ABRP sign: positive leaving the battery
Boolean charging;
Boolean dcfc;
Boolean parked;
Double lat; // °
Double lon; // °
// --- Lower priority ---
Integer rangeKm; // km, vehicle's own estimate
Float cabinTemp; // °C
Float battTempC; // °C, traction battery
Float capacityKwh; // kWh, usable pack capacity
Float soeKwh; // kWh, energy currently in the pack
Double kwhCharged; // kWh, taken by the pack during the current charge session
Float extTempC; // °C, outside
Float voltageV; // V, traction battery
Float currentA; // A, traction battery
Float odometerKm; // km
Float hvacSetpointC; // °C, climate setpoint
Float tirePressureFlKpa;
Float tirePressureFrKpa;
Float tirePressureRlKpa;
Float tirePressureRrKpa;
Double elevation; // m
Float heading; // °
TelemetryPayload(long utc) {
this.utc = utc;
}
/** Build the {@code tlm} JSON object. Only utc is always present. */
String build() {
JSONObject tlm = new JSONObject();
try {
tlm.put("utc", utc);
putIfPresent(tlm, "soc", soc);
// Reversing reads negative; ABRP and the server take speed as a magnitude.
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(Math.abs(speedKmh)));
putIfPresent(tlm, "est_battery_range", rangeKm);
if (powerKw != null) tlm.put("power", round2(powerKw));
putIfPresent(tlm, "is_charging", boolToInt(charging));
putIfPresent(tlm, "is_dcfc", boolToInt(dcfc));
putIfPresent(tlm, "is_parked", boolToInt(parked));
// Only send cabin_temp if we got a plausible reading — 0.0 likely
// means the property isn't supported on this VHAL.
if (isPlausibleTemp(cabinTemp)) {
tlm.put("cabin_temp", Math.round(cabinTemp));
}
// Same guard for the pack: a VHAL that does not implement the property
// answers 0.0, and "battery at 0 °C" is a reading ABRP acts on.
if (isPlausibleTemp(battTempC)) {
tlm.put("batt_temp", Math.round(battTempC));
}
// hvac_setpoint is a user setting, so 0 °C is not a real value either —
// no cabin climate control is ever set to freezing.
if (isPlausibleTemp(hvacSetpointC)) {
tlm.put("hvac_setpoint", Math.round(hvacSetpointC));
}
// A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time
// it runs this app: both zeros mean "property not implemented".
if (capacityKwh != null && capacityKwh > 0f) {
tlm.put("capacity", round2(capacityKwh));
}
if (odometerKm != null && odometerKm > 0f) {
tlm.put("odometer", Math.round(odometerKm));
}
// Same reasoning as capacity: a car that is running this app has some energy
// in its pack, so 0 kWh is the VHAL saying it does not implement the property.
if (soeKwh != null && soeKwh > 0f) {
tlm.put("soe", round2(soeKwh));
}
// 0.0 IS meaningful here — it is a charge session that has just begun — so
// This one is guarded on null only. EVHardware's charging-session meter sends
// null between sessions.
if (kwhCharged != null) {
tlm.put("kwh_charged", kwhCharged.doubleValue());
}
if (isPlausibleTemp(extTempC)) {
tlm.put("ext_temp", Math.round(extTempC));
}
// A pack under load is never at 0 V; 0 is the property not being implemented.
if (voltageV != null && voltageV > 0f && voltageV < 1500f) {
tlm.put("voltage", round2(voltageV));
}
// 0 A is a real reading (idle), so this one is guarded on range only.
if (currentA != null && currentA > -2000f && currentA < 2000f) {
tlm.put("current", round2(currentA));
}
putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa));
putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa));
putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa));
putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa));
if (lat != null && lon != null) {
tlm.put("lat", lat.doubleValue());
tlm.put("lon", lon.doubleValue());
putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation));
putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading));
}
} catch (JSONException e) {
// JSONObject.put only throws on NaN/Infinity values, which the guards above
// already exclude. Returning just the timestamp keeps the upload alive.
return "{\"utc\":" + utc + "}";
}
dropOutOfRange(tlm);
return tlm.toString();
}
/**
* The server's range for every numeric field (server/model.go, telemetryNumbers). A value
* outside it is left out here, as an unread one would be: the server would drop it anyway,
* and an older server rejects the whole batch for it.
*/
static final java.util.Map<String, double[]> SERVER_LIMITS = new java.util.HashMap<>();
static {
SERVER_LIMITS.put("soc", new double[]{0, 100});
SERVER_LIMITS.put("speed", new double[]{0, 400});
SERVER_LIMITS.put("power", new double[]{-1000, 1000});
SERVER_LIMITS.put("est_battery_range", new double[]{0, 2000});
SERVER_LIMITS.put("capacity", new double[]{0, 500});
SERVER_LIMITS.put("soe", new double[]{0, 500});
SERVER_LIMITS.put("kwh_charged", new double[]{0, 1000});
SERVER_LIMITS.put("odometer", new double[]{0, 10_000_000});
SERVER_LIMITS.put("lat", new double[]{-90, 90});
SERVER_LIMITS.put("lon", new double[]{-180, 180});
SERVER_LIMITS.put("elevation", new double[]{-1000, 10_000});
SERVER_LIMITS.put("heading", new double[]{0, 360});
SERVER_LIMITS.put("batt_temp", new double[]{-60, 100});
SERVER_LIMITS.put("cabin_temp", new double[]{-60, 100});
SERVER_LIMITS.put("ext_temp", new double[]{-60, 70});
SERVER_LIMITS.put("hvac_setpoint", new double[]{0, 40});
SERVER_LIMITS.put("voltage", new double[]{0, 1500});
SERVER_LIMITS.put("current", new double[]{-2000, 2000});
SERVER_LIMITS.put("tire_pressure_fl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_fr", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rl", new double[]{0, 1000});
SERVER_LIMITS.put("tire_pressure_rr", new double[]{0, 1000});
}
private static void dropOutOfRange(JSONObject tlm) {
for (java.util.Map.Entry<String, double[]> limit : SERVER_LIMITS.entrySet()) {
String key = limit.getKey();
if (!tlm.has(key)) continue;
double value = tlm.optDouble(key, Double.NaN);
if (Double.isNaN(value) || value < limit.getValue()[0] || value > limit.getValue()[1]) tlm.remove(key);
}
// A position is only a position as a pair.
if (tlm.has("lat") != tlm.has("lon")) {
tlm.remove("lat");
tlm.remove("lon");
}
}
/** Epoch seconds the server accepts: from 2001. Earlier means the head unit's clock is not set yet. */
static final long MIN_UTC = 1_000_000_000L;
static boolean isPlausibleUtc(long utcSeconds) {
return utcSeconds >= MIN_UTC;
}
/**
* True for a temperature that a sensor could actually have measured. Excludes exactly
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
*
* Package-private because the service asks the same question before deciding whether to
* look for an outside temperature somewhere other than the car.
*/
static boolean isPlausibleTemp(Float celsius) {
return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f;
}
/**
* Rounded kPa, or null when the reading is outside anything a road tyre reports.
* A flat tyre still reads well above zero, so 0 means the wheel has no sensor.
*/
private static Integer kpa(Float pressure) {
if (pressure == null || pressure < 50f || pressure > 500f) return null;
return Math.round(pressure);
}
private static void putIfPresent(JSONObject json, String key, Object value)
throws JSONException {
if (value != null) json.put(key, value);
}
private static Integer boolToInt(Boolean value) {
return value == null ? null : (value ? 1 : 0);
}
/** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */
private static double round2(float value) {
return Math.round(value * 100.0) / 100.0;
}
/**
* Every field {@link #build()} can emit, in the order the summary lists them.
* Used only to work out what was left out — see {@link #summarize(String)}.
*/
private static final String[] ALL_FIELDS = {
"utc", "soc", "speed", "power", "est_battery_range", "cabin_temp",
"batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer",
"is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading",
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr",
"ext_temp", "voltage", "current",
};
/**
* One-line, human-readable digest of an upload: what was sent, and what was left out.
*
* Derived from the JSON that actually goes on the wire rather than from the fields
* above, so it cannot drift away from the payload it claims to describe — which is
* the whole point of having it when a value on the ABRP side looks wrong.
*/
static String summarize(String tlmJson) {
JSONObject tlm;
try {
tlm = new JSONObject(tlmJson);
} catch (JSONException e) {
return "unparseable payload: " + tlmJson;
}
StringBuilder sent = new StringBuilder();
StringBuilder omitted = new StringBuilder();
for (String field : ALL_FIELDS) {
if (tlm.has(field)) {
if (sent.length() > 0) sent.append(' ');
sent.append(field).append('=').append(tlm.opt(field));
} else {
if (omitted.length() > 0) omitted.append(',');
omitted.append(field);
}
}
return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted);
}
}
@@ -0,0 +1,112 @@
package com.evsuite.abrp;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
/**
* Live samples waiting to reach the server, oldest first.
*
* Bounded: while the car is offline the oldest samples are dropped once {@link #capacity}
* is reached, so a long dead spot costs the start of the gap, never the head unit's memory.
* Kept in memory and saved to a file now and then (TelemetryUploader), so a reboot or a
* killed process on the way through a dead spot does not lose the drive. {@link #encode} and
* {@link #decode} are the file format: a JSON array of the samples as they are sent.
*
* Free of Android types so the policy is unit-tested on the JVM.
*/
final class TelemetryQueue {
/** About eight hours at one sample per 15-second tick. */
static final int DEFAULT_CAPACITY = 2000;
/** The server takes at most 500 samples per request. */
static final int MAX_BATCH = 500;
private final int capacity;
private final ArrayDeque<String> samples = new ArrayDeque<>();
private int dropped;
TelemetryQueue() { this(DEFAULT_CAPACITY); }
TelemetryQueue(int capacity) { this.capacity = capacity; }
/** Counts every change, so the owner can tell whether there is anything new to save. */
private long version;
synchronized void add(String sampleJson) {
version++;
if (samples.size() >= capacity) {
samples.removeFirst();
dropped++;
}
samples.addLast(sampleJson);
}
/** Up to {@link #MAX_BATCH} of the oldest samples, still queued until {@link #remove}. */
synchronized List<String> peekBatch() {
List<String> batch = new ArrayList<>(Math.min(samples.size(), MAX_BATCH));
for (String sample : samples) {
if (batch.size() >= MAX_BATCH) break;
batch.add(sample);
}
return batch;
}
/** Drops the oldest {@code count} samples once the server has confirmed them. */
synchronized void remove(int count) {
if (count > 0) version++;
for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst();
}
/** The newest sample, or null when the queue is empty; it stays queued. */
synchronized String newest() { return samples.peekLast(); }
synchronized int size() { return samples.size(); }
synchronized long version() { return version; }
/** Every queued sample, oldest first, as {@link #encode} writes them. */
synchronized String encode() {
org.json.JSONArray array = new org.json.JSONArray();
for (String sample : samples) array.put(sample);
return array.toString();
}
/**
* Puts saved samples back in front of anything queued since, oldest first and within the
* capacity. Unreadable input restores nothing: a damaged file costs the backlog, never
* the app.
*/
synchronized int restore(String saved) {
List<String> restored = decode(saved);
if (restored.isEmpty()) return 0;
ArrayDeque<String> merged = new ArrayDeque<>(restored);
merged.addAll(samples);
while (merged.size() > capacity) {
merged.removeFirst();
dropped++;
}
samples.clear();
samples.addAll(merged);
version++;
return restored.size();
}
static List<String> decode(String saved) {
List<String> out = new ArrayList<>();
if (saved == null || saved.isEmpty()) return out;
try {
org.json.JSONArray array = new org.json.JSONArray(saved);
for (int i = 0; i < array.length(); i++) {
String sample = array.optString(i, null);
if (sample != null && sample.startsWith("{")) out.add(sample);
}
} catch (org.json.JSONException e) {
out.clear();
}
return out;
}
/** Samples lost to the capacity limit since the queue was created. */
synchronized int dropped() { return dropped; }
}
@@ -0,0 +1,382 @@
package com.evsuite.abrp;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.List;
import java.util.Objects;
/**
* Live telemetry to the car server, which stores it, and the newest sample straight to ABRP.
*
* Called on every service tick. While the car is in use every tick becomes a sample; the
* queue is sent when {@link UploadCadence} says so — every tick on a state change, otherwise
* at the configured interval. Parked and not charging, a sample is only taken when one is
* sent, every 15 minutes: nothing changes, and a sample every 15 s overnight is waste. The GPS
* subscription follows the same rule: it is paused while parked and idle, and those samples
* carry the fix the car parked with. {@link #stop} ends it when live telemetry is off.
*
* Runs on the service's scheduler thread; the HTTP calls block it, with short timeouts.
* Location updates arrive on the main looper and are only read here.
*/
final class TelemetryUploader {
private static final String TAG = "NilsMG4.Telemetry";
/** Batches per tick: a backlog drains over a few ticks instead of stalling one. */
private static final int MAX_BATCHES_PER_TICK = 2;
/** A fix older than this no longer says where the car is; position is omitted. */
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60_000L;
private static final long LOCATION_RETRY_MS = 30_000L;
private final Context context;
private final TelemetryQueue queue = new TelemetryQueue();
private final ChargingSignal chargingSignal = new ChargingSignal();
private volatile Location lastLocation;
/** The fix kept while parked and idle, with the GPS paused; null otherwise. */
private volatile Location parkedLocation;
/** Whether the GPS should run; a request still queued on the main looper checks it. */
private volatile boolean locationWanted;
private volatile boolean locationActive;
private volatile long lastLocationRequestMs;
private LocationManager locationManager;
private long lastUploadAttemptMs;
private Boolean lastParked;
private Boolean lastCharging;
TelemetryUploader(Context context) {
this.context = context.getApplicationContext();
load();
}
// ---------- Keeping the queue across restarts ----------
private static final String QUEUE_FILE = "telemetry_queue.json";
/**
* At most this often, and only after a change: the flash is not written every 15 s, and a
* killed process loses at most this much. A clean stop saves at once ({@link #persist}).
*/
private static final long SAVE_INTERVAL_MS = 5 * 60_000L;
private long savedVersion;
private long lastSaveMs;
private android.util.AtomicFile queueFile() {
return new android.util.AtomicFile(new java.io.File(context.getFilesDir(), QUEUE_FILE));
}
private synchronized void load() {
try {
byte[] bytes = queueFile().readFully();
int restored = queue.restore(new String(bytes, java.nio.charset.StandardCharsets.UTF_8));
if (restored > 0) Log.i(TAG, restored + " queued sample(s) restored");
} catch (java.io.FileNotFoundException e) {
// nothing saved yet
} catch (Exception e) {
Log.w(TAG, "Saved queue not read: " + e.getClass().getSimpleName());
}
savedVersion = queue.version();
lastSaveMs = SystemClock.elapsedRealtime();
}
private void maybeSave() {
if (SystemClock.elapsedRealtime() - lastSaveMs >= SAVE_INTERVAL_MS) persist();
}
/** Saves the queue if it changed since the last save. Atomic: a crash keeps the old file. */
synchronized void persist() {
long version = queue.version();
lastSaveMs = SystemClock.elapsedRealtime();
if (version == savedVersion) return;
android.util.AtomicFile file = queueFile();
java.io.FileOutputStream out = null;
try {
out = file.startWrite();
out.write(queue.encode().getBytes(java.nio.charset.StandardCharsets.UTF_8));
file.finishWrite(out);
savedVersion = version;
} catch (Exception e) {
if (out != null) file.failWrite(out);
Log.w(TAG, "Queue not saved: " + e.getClass().getSimpleName());
}
}
// ---------- Tick ----------
void tick(EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session, UploadSettings settings) {
long nowMs = System.currentTimeMillis();
Integer soc = vehicle.getSocPercent() == null ? null : Math.round(vehicle.getSocPercent());
Float powerKw = vehicle.getBatteryPowerKw();
Boolean parked = vehicle.getParked();
// The charge port property need not be implemented; ChargingSignal falls back to the
// charge rate and the park state, exactly as the removed ABRP uploader did.
Boolean charging = chargingSignal.evaluate(
nowMs, vehicle.getChargePortConnected(), powerKw == null ? null : -powerKw, parked, soc);
Boolean dcfc = charging == null || powerKw == null ? null
: charging && ChargingState.isDcCharging(vehicle.getChargingStatus(),
vehicle.getChargePortConnected(), powerKw, vehicle.getSpeedKmh());
boolean stateChanged = !Objects.equals(parked, lastParked) || !Objects.equals(charging, lastCharging);
lastParked = parked;
lastCharging = charging;
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
if (idle) {
// Parked and idle the car does not move: keep one fresh fix and stop the GPS until
// the car is in use again. Without a fix yet (just started, or in a garage) it
// keeps looking.
if (parkedLocation == null) parkedLocation = freshLocation(settings);
if (parkedLocation == null) ensureLocationUpdates(settings);
else if (locationWanted) stop();
} else {
parkedLocation = null;
ensureLocationUpdates(settings);
}
boolean uploadDue = UploadCadence.shouldUpload(
nowMs, lastUploadAttemptMs, parked, charging, soc, stateChanged, settings);
// Before the head unit has set its clock the sample's time is meaningless, and the
// server would drop it anyway.
boolean clockSet = TelemetryPayload.isPlausibleUtc(nowMs / 1000);
if (clockSet && (!idle || uploadDue)) {
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked, idle));
}
if (!uploadDue) {
maybeSave();
return;
}
// Cadence counts attempts, not successes: retrying every tick while offline would
// spend power exactly when it buys nothing.
lastUploadAttemptMs = nowMs;
flush();
maybeSave();
}
int queued() { return queue.size(); }
// ---------- Sample ----------
private String buildSample(long nowMs, EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session,
UploadSettings settings, Integer soc, Boolean charging, Boolean dcfc, Boolean parked,
boolean idle) {
TelemetryPayload tlm = new TelemetryPayload(nowMs / 1000);
tlm.soc = soc;
tlm.speedKmh = vehicle.getSpeedKmh();
tlm.rangeKm = vehicle.getRangeKm() == null ? null : Math.round(vehicle.getRangeKm());
tlm.powerKw = vehicle.getBatteryPowerKw();
tlm.charging = charging;
tlm.dcfc = dcfc;
tlm.parked = parked;
tlm.cabinTemp = vehicle.getCabinTempCelsius();
tlm.battTempC = vehicle.getBatteryTempCelsius();
tlm.extTempC = vehicle.getOutsideTempCelsius();
tlm.capacityKwh = vehicle.getBatteryCapacityKwh();
tlm.soeKwh = vehicle.getBatteryEnergyKwh();
tlm.odometerKm = vehicle.getOdometerKm();
tlm.hvacSetpointC = vehicle.getClimate().getDriverTargetCelsius();
tlm.voltageV = vehicle.getBatteryVoltageV();
tlm.currentA = vehicle.getBatteryCurrentA();
// Only DC charges are metered, so only those carry an energy figure.
if (Boolean.TRUE.equals(dcfc) && session != null && session.charging) tlm.kwhCharged = session.energyKwh;
TirePressureSnapshot tires = vehicle.getTirePressures();
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
tlm.tirePressureRrKpa = tires.getRearRightKpa();
Location loc = idle ? parkedLocation : freshLocation(settings);
if (loc != null) {
tlm.lat = loc.getLatitude();
tlm.lon = loc.getLongitude();
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
}
return tlm.build();
}
// ---------- Upload ----------
private void flush() {
// ABRP first and on its own: live data must not wait for, or fail with, the server.
sendNewestToAbrp();
ServerConfig config = ServerConfig.get(context);
if (!config.isConfigured()) {
// Kept for the server once one is set up; the queue is bounded.
Log.i(TAG, "Telemetry not sent to a server: none configured (" + queue.size() + " queued)");
return;
}
for (int i = 0; i < MAX_BATCHES_PER_TICK && queue.size() > 0; i++) {
List<String> batch = queue.peekBatch();
String summary = TelemetryPayload.summarize(batch.get(batch.size() - 1));
try {
ServerHttp.Response response = post(config, batch);
int status = response.status;
boolean ok = response.ok();
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), status, ok,
ok ? "Telemetry: " + batch.size() + " sample(s) sent" + droppedNote(response)
: "Telemetry rejected (" + batch.size() + " queued): " + response.excerpt(), summary));
if (!ok) {
// The server drops bad values itself and answers 200, so a 400 is a request
// it cannot read at all and will never take: drop the batch rather than
// block the queue behind it forever. Anything else (401, 403, 5xx) is kept.
if (status == 400) queue.remove(batch.size());
return;
}
queue.remove(batch.size());
} catch (Exception e) {
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
"Telemetry offline — " + queue.size() + " queued: " + e.getClass().getSimpleName(),
summary));
return;
}
}
}
private ServerHttp.Response post(ServerConfig config, List<String> batch) throws Exception {
JSONArray samples = new JSONArray();
for (String sample : batch) samples.put(new JSONObject(sample));
JSONObject body = new JSONObject()
.put("installationId", config.installationId)
.put("samples", samples);
return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body);
}
// ---------- ABRP, directly ----------
/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */
private static final long ABRP_MAX_AGE_S = 5 * 60;
/** Time of the newest sample ABRP accepted from the car; 0 before the first. */
private volatile long lastAbrpUtc;
/**
* Sends the newest queued sample straight to ABRP when ABRP credentials are set: once per
* upload, only if it is newer than the last one sent and still live. It stays queued for
* the server either way; the server does not forward to ABRP.
*/
private void sendNewestToAbrp() {
AbrpConfig abrp = AbrpConfig.get(context);
if (!abrp.isConfigured()) return;
String newest = queue.newest();
if (newest == null) return;
long utc = utcOf(newest);
if (utc <= lastAbrpUtc || System.currentTimeMillis() / 1000 - utc > ABRP_MAX_AGE_S) return;
String summary = TelemetryPayload.summarize(newest);
try {
ServerHttp.Response response = AbrpApi.send(abrp, newest);
boolean ok = AbrpApi.accepted(response);
if (ok) lastAbrpUtc = utc;
// ABRP's answer is not quoted: it can echo the request, credentials included.
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), response.status, ok,
ok ? "ABRP: live data sent" : "ABRP rejected: HTTP " + response.status, summary));
} catch (Exception e) {
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
"ABRP offline: " + e.getClass().getSimpleName(), summary));
}
}
static long utcOf(String sampleJson) {
try {
return new JSONObject(sampleJson).optLong("utc", 0L);
} catch (Exception e) {
return 0L;
}
}
/** ", 2 value(s) dropped by the server" when it left anything out, for the upload log. */
static String droppedNote(ServerHttp.Response response) {
try {
int dropped = new JSONObject(response.body).optInt("droppedCount", 0);
return dropped > 0 ? ", " + dropped + " value(s) dropped by the server" : "";
} catch (Exception e) {
return ""; // an older server answers 204 with no body
}
}
// ---------- Location ----------
private final LocationListener locationListener = new LocationListener() {
@Override public void onLocationChanged(Location location) { lastLocation = location; }
@Override public void onStatusChanged(String provider, int status, Bundle extras) { }
@Override public void onProviderEnabled(String provider) { locationActive = false; }
@Override public void onProviderDisabled(String provider) {
// A stale fix is worse than none: it would put the car back where it was.
lastLocation = null;
locationActive = false;
}
};
/** Subscribes to GPS once, and again after a failure or a provider change. */
private void ensureLocationUpdates(UploadSettings settings) {
locationWanted = true;
if (locationActive) return;
long now = SystemClock.elapsedRealtime();
if (lastLocationRequestMs != 0L && now - lastLocationRequestMs < LOCATION_RETRY_MS) return;
lastLocationRequestMs = now;
new android.os.Handler(Looper.getMainLooper()).post(() -> requestLocationUpdates(settings));
}
private void requestLocationUpdates(UploadSettings settings) {
if (!locationWanted) return; // stopped while this request was queued
try {
if (locationManager == null) {
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
}
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) { }
if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
Log.w(TAG, "GPS provider disabled — position omitted, will retry");
return;
}
// Matched to the upload interval: fixes between uploads would only be thrown away.
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
Math.min(settings.intervalMs(), 15_000L), 0f, locationListener, Looper.getMainLooper());
locationActive = true;
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
if (last != null && locationAgeMs(last) <= MIN_LOCATION_MAX_AGE_MS) lastLocation = last;
} catch (SecurityException e) {
Log.w(TAG, "Location permission not granted: " + e.getMessage());
} catch (Exception e) {
Log.w(TAG, "Location unavailable: " + e.getMessage());
}
}
/** Ends the GPS subscription; the next tick that needs a position starts it again. */
void stop() {
locationWanted = false;
try {
if (locationManager != null) locationManager.removeUpdates(locationListener);
} catch (Exception ignored) { }
locationActive = false;
lastLocationRequestMs = 0L;
}
private Location freshLocation(UploadSettings settings) {
Location loc = lastLocation;
if (loc == null) return null;
long maxAge = Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
return locationAgeMs(loc) <= maxAge ? loc : null;
}
/**
* Age on the monotonic clock: the head unit sets its wall clock from GPS, so a fix's
* wall-clock time can jump. A provider that does not stamp the monotonic clock leaves 0;
* such a fix counts as fresh rather than blanking the position for good.
*/
private static long locationAgeMs(Location loc) {
long fixNanos = loc.getElapsedRealtimeNanos();
if (fixNanos <= 0L) return 0L;
return Math.max(0L, (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L);
}
}
@@ -0,0 +1,66 @@
package com.evsuite.abrp;
/**
* Decides whether a given scheduler tick should actually upload.
*
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
* overnight and not charging does not need a sample every minute — that is wasted data and
* power — but nothing may be lost while driving or charging, where ABRP needs the
* resolution to keep a route plan accurate.
*
* Free of Android types so the policy can be unit-tested directly.
*/
final class UploadCadence {
/**
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
* moving and its SOC is not changing, so anything more is spent for nothing.
*/
static final long PARKED_INTERVAL_MS = 900_000L;
private UploadCadence() { }
/**
* @param nowMs current time
* @param lastUploadMs when the last upload was attempted, 0 if never
* @param parked null when the gear could not be read
* @param charging null when the charge port state could not be read
* @param socPercent null when the battery level could not be read
* @param stateChanged true if parked/charging differ from the previous tick
* @param settings user-configured cadence
*/
static boolean shouldUpload(long nowMs, long lastUploadMs,
Boolean parked, Boolean charging, Integer socPercent,
boolean stateChanged, UploadSettings settings) {
// Never uploaded yet: send immediately so ABRP sees the car come online.
if (lastUploadMs <= 0L) return true;
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
// learn straight away — never make it wait for the slow interval.
if (stateChanged) return true;
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
}
/**
* Interval that applies in the given state.
*
* Unknown state counts as active: if the gear or charge port could not be read we do
* not know the car is idle, and under-reporting a moving car is worse than a few
* extra requests from a stationary one.
*/
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
UploadSettings settings) {
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
if (idle) return PARKED_INTERVAL_MS;
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
// curve to get the arrival estimate right. Only while actually in use — a car
// parked at 5 % is still parked and gains nothing from fast polling.
if (settings.boostLowSoc && socPercent != null
&& socPercent <= settings.lowSocPercent) {
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
}
return settings.intervalMs();
}
}
@@ -1,14 +1,92 @@
package com.evsuite.abrp;
import android.content.SharedPreferences;
/**
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
* there is no user-configurable upload policy left. Kept as an empty shell so any stray
* references compile during the ABRP removal; will be deleted once all callers are gone.
* User-configurable upload policy.
*
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
* and both the upload tick and the GPS subscription cost power the whole time, so the
* default cadence is the slowest one that still keeps an ABRP route plan usable.
*/
final class UploadSettings {
static final String KEY_AUTOSTART = "autostart";
static final boolean DEFAULT_AUTOSTART = true;
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
static final String KEY_AUTOSTART = "autostart";
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
/** Hide the head unit's system bars while the app is open. */
static final String KEY_FULL_SCREEN = "full_screen";
private UploadSettings() { }
static boolean fullScreen(SharedPreferences prefs) {
return prefs.getBoolean(KEY_FULL_SCREEN, true);
}
/** A double press of the steering wheel's voice key opens the app. */
static final String KEY_VOICE_KEY_OPENS = "voice_key_opens_app";
static boolean voiceKeyOpensApp(SharedPreferences prefs) {
return prefs.getBoolean(KEY_VOICE_KEY_OPENS, true);
}
/**
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
* starts, and nobody opens this app before setting off. Kept as a switch because a
* service that starts itself must also be switchable off.
*/
static final boolean DEFAULT_AUTOSTART = true;
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
/**
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
* speed, which ABRP can still track; going much slower makes the live plan lag
* reality. Parked and idle backs off far further on its own — see UploadCadence.
*/
static final int DEFAULT_INTERVAL_SEC = 60;
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
static final boolean DEFAULT_BOOST_LOW_SOC = true;
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
static final int DEFAULT_LOW_SOC_PERCENT = 20;
/** Cadence used while SOC is low and the boost is enabled. */
static final int BOOSTED_INTERVAL_SEC = 15;
final int intervalSec;
final boolean boostLowSoc;
final int lowSocPercent;
final boolean abrpUploadEnabled;
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
this(intervalSec, boostLowSoc, lowSocPercent, DEFAULT_ABRP_UPLOAD_ENABLED);
}
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent,
boolean abrpUploadEnabled) {
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
this.abrpUploadEnabled = abrpUploadEnabled;
}
static UploadSettings from(SharedPreferences prefs) {
return new UploadSettings(
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT),
prefs.getBoolean(KEY_ABRP_UPLOAD_ENABLED, DEFAULT_ABRP_UPLOAD_ENABLED));
}
static UploadSettings defaults() {
return new UploadSettings(
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
}
long intervalMs() {
return intervalSec * 1000L;
}
}
@@ -1,163 +0,0 @@
package com.evsuite.abrp;
import android.content.Context;
import android.net.wifi.WifiConfiguration;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.util.Log;
import java.util.List;
/**
* Brings Wi-Fi up and joins a network the head unit already knows.
*
* The point is the phone hotspot: the car has the credentials saved, but the driver still
* has to walk into Settings and tap the network every time. This does that tap.
*
* <b>Why this works here and would not on a newer phone.</b> {@code getConfiguredNetworks}
* and {@code enableNetwork} were deprecated in API 29 and, from that release, return an
* empty list and refuse the call for ordinary apps — Android handed network choice to the
* user through a system picker. This head unit runs Android 9 (API 28), where both are
* still honoured, and the build is platform-signed, so it is the same UID family the
* Settings app itself uses. Neither fact is portable: on a Pixel this class would find
* nothing and change nothing. It is deliberately written to fail quietly if so.
*
* Nothing here creates or edits a network. It only enables one the unit was already
* configured with, which is why no password ever passes through this app.
*/
final class WifiAutoConnect {
private static final String TAG = "EVABRP.Wifi";
/** Re-checking faster than this achieves nothing: a join takes seconds to settle. */
private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L;
private final WifiManager wifi;
private long lastAttemptMs;
WifiAutoConnect(Context context) {
this.wifi = (WifiManager) context.getApplicationContext()
.getSystemService(Context.WIFI_SERVICE);
}
/** The SSID the unit is associated with, or null when it is not on Wi-Fi. */
String currentSsid() {
try {
if (wifi == null || !wifi.isWifiEnabled()) return null;
WifiInfo info = wifi.getConnectionInfo();
if (info == null) return null;
String ssid = info.getSSID();
if (ssid == null) return null;
// The framework quotes the SSID and reports this placeholder when it will not
// say — usually because location is off. Neither is a network name.
ssid = ssid.replace("\"", "").trim();
if (ssid.isEmpty() || "<unknown ssid>".equals(ssid) || "0x".equals(ssid)) return null;
return ssid;
} catch (Throwable t) {
return null;
}
}
boolean isWifiEnabled() {
try {
return wifi != null && wifi.isWifiEnabled();
} catch (Throwable t) {
return false;
}
}
/** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */
List<String> savedNetworkNames() {
java.util.List<String> names = new java.util.ArrayList<>();
try {
if (wifi == null) return names;
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
if (saved == null) return names;
for (WifiConfiguration c : saved) {
if (c == null || c.SSID == null) continue;
String name = c.SSID.replace("\"", "").trim();
if (!name.isEmpty() && !names.contains(name)) names.add(name);
}
} catch (SecurityException e) {
Log.w(TAG, "Saved networks unavailable: permission denied");
} catch (Throwable t) {
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
}
return names;
}
/**
* One attempt to get onto a saved network, rate-limited.
*
* @param force ignore the rate limit — for the button, which a driver only presses
* because the automatic attempt has not worked yet.
* @return what happened, for the log and the UI. Never null.
*/
Result ensureConnected(boolean force) {
if (wifi == null) return Result.UNSUPPORTED;
String ssid = currentSsid();
if (ssid != null) return Result.alreadyOn(ssid);
long now = System.currentTimeMillis();
if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING;
lastAttemptMs = now;
try {
if (!wifi.isWifiEnabled()) {
// Also gone for ordinary apps from API 29, and also still honoured here.
if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE;
// The radio needs a moment before it will associate; the next tick retries.
return Result.ENABLING;
}
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS;
// A scan would let us prefer a network that is actually in range, but the results
// are themselves throttled and location-gated. Asking the supplicant to pick from
// what it knows is both simpler and what the Settings screen effectively does.
wifi.reconnect();
for (WifiConfiguration c : saved) {
if (c == null) continue;
// Second argument true: disable the others for this attempt, so the
// supplicant commits to this one rather than drifting between them.
if (wifi.enableNetwork(c.networkId, true)) {
String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim();
Log.i(TAG, "enableNetwork accepted for " + name);
return Result.attempting(name);
}
}
return Result.REFUSED;
} catch (SecurityException e) {
Log.w(TAG, "Wi-Fi connection refused: permission denied");
return Result.REFUSED;
} catch (Throwable t) {
Log.w(TAG, "connect attempt failed: " + t.getMessage());
return Result.REFUSED;
}
}
/** Outcome of one attempt. {@link #connected} is the only one that means we are online. */
static final class Result {
final boolean connected;
final String ssid;
final String message;
private Result(boolean connected, String ssid, String message) {
this.connected = connected;
this.ssid = ssid;
this.message = message;
}
static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); }
static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); }
static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor");
static final Result WAITING = new Result(false, null, "Bekleniyor…");
static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…");
static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı");
static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok");
static final Result REFUSED = new Result(false, null, "Sistem izin vermedi");
}
}
+1 -1
View File
@@ -1 +1 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_focused="true" android:color="@color/ev_accent"/><item android:color="@color/ev_outline"/></selector>
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_focused="true" android:color="?attr/evAccent"/><item android:color="@color/ev_outline"/></selector>
@@ -1 +1 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="@color/ev_gold_surface"/><item android:color="@color/ev_transparent"/></selector>
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="?attr/evAccentSurface"/><item android:color="@color/ev_transparent"/></selector>
@@ -1 +1 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="@color/ev_accent"/><item android:color="@color/ev_text_secondary"/></selector>
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="?attr/evAccent"/><item android:color="@color/ev_text_secondary"/></selector>
+1 -1
View File
@@ -1 +1 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="#806F6045"/><item android:color="@color/ev_transparent"/></selector>
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="?attr/evAccentStroke"/><item android:color="@color/ev_transparent"/></selector>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="@color/ev_outline" />
<item android:state_checked="true" android:color="@color/ev_cyan" />
<item android:state_checked="true" android:color="?attr/evAccentLight" />
<item android:color="@color/ev_text_secondary" />
</selector>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="@color/ev_surface_raised" />
<item android:state_checked="true" android:color="#345E60" />
<item android:state_checked="true" android:color="?attr/evAccentTrack" />
<item android:color="@color/ev_outline" />
</selector>
+15 -1
View File
@@ -1 +1,15 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"><gradient android:angle="315" android:startColor="#E5183032" android:centerColor="#F012191A" android:endColor="#FA121212"/><corners android:radius="24dp"/><stroke android:width="1dp" android:color="#40528C8C"/></shape>
<?xml version="1.0" encoding="utf-8"?>
<!-- The accent-tinted card: an accent surface under a neutral fade. Built as layers because a
gradient with ?attr colours crashes on Android 9 (fixed in 10); solid and stroke are fine. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape><solid android:color="?attr/evAccentSurface"/><corners android:radius="24dp"/></shape>
</item>
<item>
<shape>
<gradient android:angle="315" android:startColor="#00121212" android:centerColor="#D0121212" android:endColor="#FA121212"/>
<corners android:radius="24dp"/>
<stroke android:width="1dp" android:color="?attr/evAccentStroke"/>
</shape>
</item>
</layer-list>
+1 -1
View File
@@ -1 +1 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"><solid android:color="#101213"/><corners android:radius="16dp"/><stroke android:width="1dp" android:color="#263233"/></shape>
<shape xmlns:android="http://schemas.android.com/apk/res/android"><solid android:color="#101213"/><corners android:radius="16dp"/><stroke android:width="1dp" android:color="?attr/evAccentDeep"/></shape>
+1 -1
View File
@@ -9,7 +9,7 @@
</item>
<item android:gravity="bottom" android:height="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/ev_accent" />
<solid android:color="?attr/evAccent" />
</shape>
</item>
</layer-list>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/ev_cyan_deep" />
<stroke android:width="1dp" android:color="@color/ev_cyan" />
<solid android:color="?attr/evAccentDeep" />
<stroke android:width="1dp" android:color="?attr/evAccentLight" />
<corners android:radius="14dp" />
</shape>
@@ -2,5 +2,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M6,9 L12,15 L18,9" android:fillColor="@android:color/transparent"
android:strokeColor="@color/ev_accent" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
android:strokeColor="?attr/evAccent" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>
+1 -1
View File
@@ -1 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M12,3 A9,9 0,1 0,12 21 A9,9 0,1 0,12 3 M12,10 L12,17 M12,7 L12,7.2" android:fillColor="#00000000" android:strokeColor="#D8C39A" android:strokeWidth="1.6" android:strokeLineCap="round"/></vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M12,3 A9,9 0,1 0,12 21 A9,9 0,1 0,12 3 M12,10 L12,17 M12,7 L12,7.2" android:fillColor="#00000000" android:strokeColor="?attr/evAccent" android:strokeWidth="1.6" android:strokeLineCap="round"/></vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Lightning bolt: the Charging tab. Tinted by @color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M13,2L4,14H11L10,22L20,9H13L13,2Z" />
</vector>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Broadcast waves around a dot: the Live data tab. Tinted by @color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,10a2,2 0,1 1,0 4a2,2 0,1 1,0 -4z" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M8.5,8.5A5,5 0,0 0,8.5 15.5M15.5,8.5A5,5 0,0 1,15.5 15.5M5.6,5.6A9,9 0,0 0,5.6 18.4M18.4,5.6A9,9 0,0 1,18.4 18.4" />
</vector>
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Terminal : onglet Log. Teinté par @color/nav_tab_foreground. -->
<!-- Terminal: the Logs tab. Tinted by @color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Arrow into a tray: the Server tab (where the telemetry goes). Tinted by
@color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M4,14V19H20V14" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M12,3V13M8,7L12,3L16,7" />
</vector>
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Engrenage : onglet Service. Teinté par @color/nav_tab_foreground. -->
<!-- Gear: the Settings tab. Tinted by @color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Wi-Fi arcs, for the Wi-Fi page. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
</vector>
@@ -1,5 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="92dp" android:height="180dp" android:viewportWidth="92" android:viewportHeight="180">
<path android:pathData="M27,12 Q46,5 65,12 Q74,18 74,38 L78,130 Q79,157 64,164 L28,164 Q13,157 14,130 L18,38 Q18,18 27,12Z" android:fillColor="#151D1E" android:strokeColor="#85BDBF" android:strokeWidth="1.4"/>
<path android:pathData="M24,45 Q46,37 68,45 L63,65 Q46,61 29,65Z M28,129 Q46,135 64,129 L66,148 Q46,155 26,148Z M28,72 L28,122 M64,72 L64,122 M23,28 L35,25 M57,25 L69,28 M23,157 L35,159 M57,159 L69,157" android:fillColor="#102D30" android:strokeColor="#527B7C" android:strokeWidth="1.3"/>
<path android:pathData="M10,43 L10,67 M82,43 L82,67 M10,120 L10,144 M82,120 L82,144" android:fillColor="#00000000" android:strokeColor="#D8C39A" android:strokeWidth="5" android:strokeLineCap="round"/>
<path android:pathData="M27,12 Q46,5 65,12 Q74,18 74,38 L78,130 Q79,157 64,164 L28,164 Q13,157 14,130 L18,38 Q18,18 27,12Z" android:fillColor="?attr/evAccentSurface" android:strokeColor="?attr/evAccentLight" android:strokeWidth="1.4"/>
<path android:pathData="M24,45 Q46,37 68,45 L63,65 Q46,61 29,65Z M28,129 Q46,135 64,129 L66,148 Q46,155 26,148Z M28,72 L28,122 M64,72 L64,122 M23,28 L35,25 M57,25 L69,28 M23,157 L35,159 M57,159 L69,157" android:fillColor="?attr/evAccentDeep" android:strokeColor="?attr/evAccentStroke" android:strokeWidth="1.3"/>
<path android:pathData="M10,43 L10,67 M82,43 L82,67 M10,120 L10,144 M82,120 L82,144" android:fillColor="#00000000" android:strokeColor="?attr/evAccent" android:strokeWidth="5" android:strokeLineCap="round"/>
</vector>
@@ -4,25 +4,23 @@
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal">
<ScrollView android:layout_width="192dp" android:layout_height="match_parent" android:layout_margin="16dp" android:fillViewport="true" android:background="@drawable/bg_sidebar" android:scrollbars="none">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="@color/ev_accent" android:paddingStart="14dp" android:paddingTop="8dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent" android:paddingStart="14dp" android:paddingTop="8dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Secondary" android:paddingStart="14dp" android:layout_marginBottom="24dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_charging" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_live" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabSettings" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_settings" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
</LinearLayout>
</ScrollView>
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="8dp">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:id="@+id/header_title" android:textSize="32sp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:id="@+id/header_subtitle" android:layout_marginTop="6dp" />
</LinearLayout>
<TextView android:id="@+id/version_badge" style="@style/Text.EV.VersionBadge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/update_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/update_check" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent" />
<com.google.android.material.button.MaterialButton android:id="@+id/about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:layout_marginStart="8dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/about_action" app:icon="@drawable/ic_info" app:iconGravity="textStart" app:iconPadding="0dp" />
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
layouts). Version, update check and About are on the Settings tab. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="true" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="10dp" android:paddingBottom="2dp">
<TextView android:id="@+id/header_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:textSize="24sp" android:maxLines="1" />
<TextView android:id="@+id/header_subtitle" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" />
</LinearLayout>
@@ -1,24 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" 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" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" 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_autostart" android:textAppearance="@style/Text.EV.Secondary" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<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/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
</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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_abrp_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" 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/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
<Spinner android:id="@+id/interval_spinner" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:layout_marginTop="12dp" android:background="@drawable/bg_input_panel" android:popupBackground="@color/ev_surface_raised" android:contentDescription="@string/label_frequency" android:spinnerMode="dropdown" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_frequency" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/boost_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_boost_low_soc" 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" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/low_soc_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/label_low_soc_threshold">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/low_soc_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="number" android:maxLength="2" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" 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/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" 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/interval_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/hint_frequency" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/boost_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_boost_low_soc" 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" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/low_soc_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/label_low_soc_threshold">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/low_soc_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="number" android:maxLength="2" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
@@ -0,0 +1,56 @@
<?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">
<!-- Three columns: display and steering wheel | battery | charging price and the app. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<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/design_display_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_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: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="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" />
</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="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/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 android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<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/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_soh_value"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/settings_soh_note" android:textAppearance="@style/Text.EV.Secondary"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/hint_usable_capacity" app:suffixText="@string/unit_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/btn_save_capacity"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" 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/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_price"/>
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
</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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_app_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/settings_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_update_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_check_update" app:icon="@drawable/ic_refresh" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_usb_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_usb_update" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_about" app:icon="@drawable/ic_info" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
@@ -1,6 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_energy_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_usage_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_usage_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_power_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_speed_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="16dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_energy" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="12dp" />
@@ -17,45 +75,25 @@
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_tires" android:textAppearance="@style/Text.EV.Eyebrow" />
<LinearLayout android:layout_width="match_parent" android:layout_height="224dp" android:orientation="horizontal" android:gravity="center_vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="196dp" android:orientation="horizontal" android:gravity="center_vertical">
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_fl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_fl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
<Space android:layout_width="1dp" android:layout_height="44dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_rl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_rl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
</LinearLayout>
<ImageView android:layout_width="92dp" android:layout_height="180dp" android:src="@drawable/ic_vehicle_outline" android:contentDescription="@string/design_car_desc" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_fr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_fr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
<Space android:layout_width="1dp" android:layout_height="44dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_rr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_rr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
</LinearLayout>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_no_data" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="16dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1.3" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/wifi_ssid" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Metric" android:layout_marginTop="20dp" />
<TextView android:id="@+id/wifi_state" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/wifi_connect_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_connect_now" android:layout_marginTop="24dp" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/wifi_auto_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/wifi_auto_label" 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/wifi_auto_hint" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" 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/wifi_saved_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/wifi_saved_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:textAppearance="@style/Text.EV.Body" android:lineSpacingExtra="16dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -4,14 +4,14 @@
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_title" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="24dp"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="32dp" android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="@color/ev_accent"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Title" android:layout_marginTop="12dp"/>
<TextView android:id="@+id/about_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="8dp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_attribution" android:textAppearance="@style/Text.EV.Secondary" android:lineSpacingMultiplier="1.3" android:layout_marginTop="24dp"/>
</LinearLayout>
<LinearLayout android:layout_width="240dp" android:layout_height="wrap_content" android:orientation="vertical">
<ImageView android:id="@+id/about_qr_code" android:layout_width="160dp" android:layout_height="160dp" android:layout_gravity="center_horizontal" android:background="#FFFFFF" android:padding="8dp" android:scaleType="fitCenter" android:contentDescription="@string/about_qr_description"/>
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:paddingTop="12dp" android:paddingBottom="12dp"/>
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:paddingTop="12dp" android:paddingBottom="12dp"/>
</LinearLayout>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/ev_outline" android:layout_marginTop="24dp" android:layout_marginBottom="12dp"/>
+9 -11
View File
@@ -2,22 +2,20 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/app_root" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:background="@color/ev_background">
<com.evsuite.abrp.PremiumBackdropView android:layout_width="match_parent" android:layout_height="match_parent"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="8dp">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:id="@+id/header_title" android:textSize="24sp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:id="@+id/header_subtitle" android:layout_marginTop="6dp"/>
</LinearLayout>
<TextView android:id="@+id/version_badge" style="@style/Text.EV.VersionBadge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp"/>
<com.google.android.material.button.MaterialButton android:id="@+id/update_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/update_check" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent"/>
<com.google.android.material.button.MaterialButton android:id="@+id/about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:layout_marginStart="8dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/about_action" app:icon="@drawable/ic_info" app:iconGravity="textStart" app:iconPadding="0dp"/>
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
layouts). Version, update check and About are on the Settings tab. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="true" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="10dp" android:paddingBottom="2dp">
<TextView android:id="@+id/header_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:textSize="24sp" android:maxLines="1" />
<TextView android:id="@+id/header_subtitle" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="8dp" android:background="@drawable/bg_sidebar">
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_charging"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_live"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabSettings" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_settings" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
</LinearLayout>
</LinearLayout>
+2 -2
View File
@@ -2,13 +2,13 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_glass" android:clipToOutline="true">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="28dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_title" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="24dp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="@color/ev_accent"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Title" android:layout_marginTop="12dp"/>
<TextView android:id="@+id/about_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="8dp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_attribution" android:textAppearance="@style/Text.EV.Secondary" android:lineSpacingMultiplier="1.3" android:layout_marginTop="24dp"/>
<Space android:layout_width="1dp" android:layout_height="24dp"/>
<ImageView android:id="@+id/about_qr_code" android:layout_width="160dp" android:layout_height="160dp" android:layout_gravity="center_horizontal" android:background="#FFFFFF" android:padding="8dp" android:scaleType="fitCenter" android:contentDescription="@string/about_qr_description"/>
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:paddingTop="12dp" android:paddingBottom="12dp"/>
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:paddingTop="12dp" android:paddingBottom="12dp"/>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/ev_outline" android:layout_marginTop="24dp" android:layout_marginBottom="12dp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_made_with_love" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginBottom="12dp"/>
<com.google.android.material.button.MaterialButton android:id="@+id/about_close" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_close"/>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One row of a drop-down list (Widget.EV.Dropdown): a full touch target, body text size. -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/touch_target"
android:gravity="center_vertical"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:textAppearance="@style/Text.EV.Body"
android:textColor="@color/ev_text_primary"
android:maxLines="1"
android:ellipsize="end" />
+14 -21
View File
@@ -1,31 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Şarj yok" android:textAppearance="@style/Text.EV.Title" android:textColor="@color/ev_cyan"/>
<TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/charging_not_charging" android:textAppearance="@style/Text.EV.Title" android:textColor="?attr/evAccentLight"/>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2" android:layout_marginTop="12dp">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Başlangıç SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_start_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya voltajı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya akımı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj gücü" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Bataryaya giren net enerji" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şebekeden alınan (+%10)" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_grid_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj süresi" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Toplam ücret" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_total_cost" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_soc" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_start_soc" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_start_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_battery_voltage" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_battery_current" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_power_label" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_net_energy_into_battery" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_drawn_from_grid" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_grid_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_time_label" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_total_cost" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_total_cost" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
</GridLayout>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ FİYATI" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Birim fiyat" app:suffixText="₺/kWh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Fiyatı kaydet"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ EĞRİSİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/charging_curve_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="280dp" android:layout_marginTop="12dp"/>
<TextView android:id="@+id/charge_curve_table" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:fontFamily="monospace" android:text="Henüz şarj ölçümü yok" android:textAppearance="@style/Text.EV.Body" android:textColor="@color/ev_cyan"/>
<TextView android:id="@+id/charge_curve_table" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:fontFamily="monospace" android:text="@string/charging_no_charging_measurements_yet" android:textAppearance="@style/Text.EV.Body" android:textColor="?attr/evAccentLight"/>
</LinearLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
+20 -28
View File
@@ -1,43 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" 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="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_refresh" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="Yenile" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent"/>
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/consumption_period_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_refresh" 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>
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="Tüm zamanlar"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_week" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="Son 7 gün"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_month" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="Son 30 gün"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_a" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="Trip A"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_b" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="Trip B"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="@string/consumption_lifetime"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_week" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_last_7_days"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_month" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_last_30_days"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_a" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_trip_a"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_b" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_trip_b"/>
</LinearLayout>
</HorizontalScrollView>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="Mesafe" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_distance" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="Net enerji" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="Ortalama tüketim" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_average" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="Ortalama hız" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_speed" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="Süre" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_time" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="SOC farkı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_distance" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_distance" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_net_energy" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_average_consumption" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_average" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_average_speed" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_speed" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_duration" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_time" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_soc_change" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
</GridLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="Seçili tripi sıfırla" android:visibility="gone"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="@string/consumption_reset_selected_trip" android:visibility="gone"/>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Son 7/30 gün, tüm zamanlar ve Trip A/B cihazda kalıcı saklanır; uygulama ve OTA güncellemeleriyle silinmez." android:textAppearance="@style/Text.EV.Secondary"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/consumption_stored_note" android:textAppearance="@style/Text.EV.Secondary"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="BATARYA SAĞLIĞI (SOH)" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Manuel SoH (%)" app:suffixText="%">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="SoH değerini kaydet"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Bu manuel değer cihazda saklanır ve MongoDB'deki tüm zamanlar kaydına eklenir." android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="GÜNLÜK KAYIT · SON 4 AY" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="Tarih seç"/>
<TextView android:id="@+id/consumption_daily_history" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:fontFamily="monospace" android:text="Henüz günlük veri yok" android:textAppearance="@style/Text.EV.Body" android:textColor="@color/ev_cyan"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/consumption_daily_log_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="@string/consumption_pick_a_date"/>
<TextView android:id="@+id/consumption_daily_history" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:fontFamily="monospace" android:text="@string/consumption_no_daily_data_yet" android:textAppearance="@style/Text.EV.Body" android:textColor="?attr/evAccentLight"/>
</LinearLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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" />
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
+26
View File
@@ -0,0 +1,26 @@
<?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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<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/server_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_url_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_url" app:helperText="@string/helper_server_url">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textUri" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/server_pair_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_pair" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/pair_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_server_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<TextView android:id="@+id/installation_id_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="14dp" android:textAppearance="@style/Text.EV.Secondary" android:textIsSelectable="true" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/server_save_button" style="@style/Widget.EV.Button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginEnd="12dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/server_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginEnd="12dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_import" />
</LinearLayout>
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
+21 -9
View File
@@ -1,20 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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" android:padding="24dp">
<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/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" 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/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" 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_autostart" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_abrp_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
</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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
<Spinner android:id="@+id/interval_spinner" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:layout_marginTop="12dp" android:background="@drawable/bg_input_panel" android:popupBackground="@color/ev_surface_raised" android:contentDescription="@string/label_frequency" android:spinnerMode="dropdown" />
<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/interval_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/hint_frequency" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/boost_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_boost_low_soc" 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" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/low_soc_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/label_low_soc_threshold">
@@ -22,4 +34,4 @@
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>
+49
View File
@@ -0,0 +1,49 @@
<?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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<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/design_display_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_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: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="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" />
</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="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/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 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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_soh_value"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/settings_soh_note" android:textAppearance="@style/Text.EV.Secondary"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/hint_usable_capacity" app:suffixText="@string/unit_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/btn_save_capacity"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
</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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_price"/>
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
</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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_app_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/settings_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_update_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_check_update" app:icon="@drawable/ic_refresh" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_usb_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_usb_update" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_about" app:icon="@drawable/ic_info" />
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
+63 -28
View File
@@ -1,6 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
<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:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:columnCount="2">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_energy_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_usage_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_usage_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_usage_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_power_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_speed_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
</GridLayout>
<Space android:layout_width="1dp" android:layout_height="16dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_energy" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="12dp" />
<TableLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:stretchColumns="0" android:shrinkColumns="0">
@@ -16,44 +71,24 @@
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_tires" android:textAppearance="@style/Text.EV.Eyebrow" />
<LinearLayout android:layout_width="match_parent" android:layout_height="224dp" android:orientation="horizontal" android:gravity="center_vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="196dp" android:orientation="horizontal" android:gravity="center_vertical">
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_fl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_fl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
<Space android:layout_width="1dp" android:layout_height="44dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_rl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_rl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
</LinearLayout>
<ImageView android:layout_width="92dp" android:layout_height="180dp" android:src="@drawable/ic_vehicle_outline" android:contentDescription="@string/design_car_desc" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_fr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_fr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
<Space android:layout_width="1dp" android:layout_height="44dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
<TextView android:id="@+id/vehicle_tire_rr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
<TextView android:id="@+id/vehicle_tire_rr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
</LinearLayout>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_no_data" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="16dp" />
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:columnCount="2">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout>
</GridLayout>
</LinearLayout>
</ScrollView>
</com.evsuite.abrp.StableScrollView>

Some files were not shown because too many files have changed in this diff Show More