Compare commits

...
4 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 bae56bec2f Server 2.6.1: code field upper-cases and inserts the dash
The phone pages' code field gets one inline script, allowed by its
SHA-256 hash in the CSP; a test checks the served script against the
header. Image pinned by digest in k8s/server.yaml (not applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 08:19:00 +02:00
nilsandClaude Opus 5.5 31ea4e7d78 README: current tabs, updating, emulator; screenshots of all tabs from 2.18.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 23:42:16 +02:00
nilsandClaude Opus 5.5 496fb821c3 App 2.18.1: every tab fits the car's window without scrolling
Charging and Consumption get two-column wide layouts; Live data and
Settings are tightened (fields beside their buttons, button rows,
16 dp vertical card padding). Checked on the emulator at 1778x640.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 22:11:05 +02:00
nilsandClaude Opus 5.5 dfd8569cfa AGENTS.md, SPECS: work happens on main now
english-translation is merged and deleted, master renamed to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:25:27 +02:00
26 changed files with 249 additions and 71 deletions
+1 -2
View File
@@ -25,8 +25,7 @@ MG4 head unit (Android 9, AAOS, MT2712)
dashboards, Traefik routes). See `k8s/README.md`.
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch
`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), branch `main`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
## Rules
+2 -2
View File
@@ -10,7 +10,7 @@ build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnes
car server /v1/app/releases, /v1/app/assets/…
│ the car's own paired token
▼
app: refresh button → check → SHA-256 → install
app: Settings → Check for updates → SHA-256 → install
```
The repository stays private and the forge token stays on the server: the car asks its own
@@ -51,7 +51,7 @@ The version comes from the **asset file name**, not the tag.
## Installing
Press the refresh button in the app's top bar. The app checks, downloads the APK into
Press **Check for updates** on the Settings tab. 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
+46 -17
View File
@@ -25,7 +25,7 @@ APIs.
## Contents
- [Screenshot](#screenshot)
- [Screenshots](#screenshots)
- [How it fits together](#how-it-fits-together)
- [The app](#the-app)
- [Install on the car](#install-on-the-car)
@@ -36,15 +36,31 @@ APIs.
- [Project documents](#project-documents)
- [Credits](#credits)
## Screenshot
## Screenshots
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).
Version 2.18.1 in the car's app window (1778×640 dp next to the MG4's own sidebar), full screen,
on the Android 9 emulator. There is no vehicle there, so every value shows as a dash. Every tab
fits the window without scrolling.
<p align="center">
<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">
<img src="screenshots/nilsmg4-2.18.1-overview.png" width="900" alt="Overview tab: battery, range, power and speed tiles with since-start and 5-minute averages, vehicle info and tyre pressures">
</p>
<table>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-consumption.png" width="440" alt="Consumption tab: period totals beside the daily log"></td>
<td><img src="screenshots/nilsmg4-2.18.1-charging.png" width="440" alt="Charging tab: live charging values beside the SOC/power curve"></td>
</tr>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-live-data.png" width="440" alt="Live data tab: on/off, ABRP credentials, upload frequency"></td>
<td><img src="screenshots/nilsmg4-2.18.1-server.png" width="440" alt="Server tab: URL, pairing, ingest token, installation ID"></td>
</tr>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-settings.png" width="440" alt="Settings tab: display, battery, charging price, phone entry, app updates"></td>
<td><img src="screenshots/nilsmg4-2.18.1-logs.png" width="440" alt="Logs tab: recent uploads"></td>
</tr>
</table>
## How it fits together
```
@@ -63,12 +79,12 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| 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 |
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read every half second |
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (usable capacity, manual SoH), charging price, version, update check, About |
| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
| **Logs** | Upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
@@ -107,9 +123,15 @@ adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
> ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md).
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.
After that first install, the app updates itself, two ways (details in [`OTA.md`](OTA.md)):
- **Over the air:** publish a build with `publish-car`, then press **Check for updates** on the
Settings tab. The car fetches it through your car server, which relays the forge's release, so
the car never holds a forge credential.
- **From a USB stick:** copy `NilsMG4-unstable-<version>.apk` and its `.sha256` from `build/car/`
to the stick's top folder and press **Install from USB stick** on the Settings tab.
Either way the app checks the SHA-256, the package and the signature before installing.
## Setup
@@ -122,7 +144,7 @@ server, so the repository stays private.
### On your phone
Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a
Once the car is paired, **Enter on phone** (Live data tab, and Settings → On your phone) 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
@@ -186,13 +208,19 @@ Tests and lint:
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
```
A head-unit emulator for checking layouts and installs: `nix run .#car-emulator` (add
`-no-window` for headless) starts Android 9 at 1920×720 and 160 dpi. It is signed with the same
platform key as the car, so the car build installs there as the system user;
`adb shell wm size 1778x640` gives the car's app window. It has no vehicle, so values stay "—".
[`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
chunks, charging sessions, pairing, settings entered on a phone, and the release relay for the
in-app updater. 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
@@ -205,13 +233,13 @@ work for the plain Gradle builds.
```
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/unstable car build: system UID, updater (via the car server) and USB install
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
flake.nix dev shell, build-car, publish-car, car-emulator
```
## Project documents
@@ -219,8 +247,9 @@ flake.nix dev shell and build-car
| Document | What it covers |
|---|---|
| [SPECS.md](SPECS.md) | Journal of this fork: architecture, status, to-dos, done log |
| [OTA.md](OTA.md) | Publishing releases, the updater's checks, USB install |
| [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 |
| [AGENTS.md](AGENTS.md) | Overview of the repository for AI agents and new contributors |
| [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 |
+19
View File
@@ -112,10 +112,29 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
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 — `english-translation` merged into the default branch (merge commit `f54e684`, the
summary as its message), `master` renamed to `main`, the work branch deleted. Work goes on `main`.
- 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-19 — Server 2.6.1: the code field on the phone pages (`/pair`, `/settings`, when opened
without the code in the link) upper-cases what is typed and puts the dash in after the third
character. That takes the pages' first script: one inline script, allowed by its SHA-256 hash in
the CSP (`script-src 'sha256-…'`, still `default-src 'none'`); a test checks the served script
against the header. Without it the server still accepts the code in any case, with or without
the dash. Image pinned by digest in `k8s/server.yaml` (not applied).
- 2026-10-18 — README brought up to date: screenshots of all seven tabs from 2.18.1 at the car's
1778×640 window (the upstream and 2.8.2 screenshots are gone), tab descriptions, updating over
the air and from a USB stick, the emulator, the release relay. OTA.md: updates are checked from
Settings, no longer from a top-bar button.
- 2026-10-18 — App 2.18.1: every tab fits the car's window (1778×640 dp) without scrolling — checked
on the emulator by swiping each page and comparing screenshots. Charging and Consumption get wide
layouts with two columns (values | curve; period totals | daily log and storage note); the curve
table scrolls inside its card, so a long session no longer stretches the page. Live data: ABRP key
and token side by side, Save / Test / Enter on phone in one row. Settings: save buttons beside
their fields, charging price under Battery, "Enter on phone" in a card of its own above App. Wide
pages use 12 dp top padding. With real data the value texts are longer; not tried on the car.
- 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
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 95
versionName "2.18.0"
versionCode 96
versionName "2.18.1"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -0,0 +1,27 @@
<?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">
<!-- Two columns: live values | curve. The table scrolls inside its card so a long session does not push the page. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="16dp" 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="@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="@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="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/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="250dp" android:layout_marginTop="12dp"/>
<androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="170dp" android:layout_marginTop="16dp" android:scrollbars="none">
<TextView android:id="@+id/charge_curve_table" android:layout_width="match_parent" android:layout_height="wrap_content" android:fontFamily="monospace" android:text="@string/charging_no_charging_measurements_yet" android:textAppearance="@style/Text.EV.Body" android:textColor="?attr/evAccentLight"/>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
@@ -0,0 +1,40 @@
<?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">
<!-- Two columns: period totals | daily log and the storage note. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="16dp" android:orientation="vertical">
<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="@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="@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="@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="@string/consumption_reset_selected_trip" android:visibility="gone"/>
</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="20dp" android:background="@drawable/bg_glass">
<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>
<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>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
@@ -1,30 +1,34 @@
<?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="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" 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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" 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" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:paddingStart="16dp" android:paddingEnd="16dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:layout_marginTop="12dp" android:layout_marginBottom="4dp" 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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<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" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" 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="0dp" android:layout_weight="1" android:layout_height="wrap_content" 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>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_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" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_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_marginStart="8dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginStart="8dp" />
</LinearLayout>
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" 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">
<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:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" 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">
@@ -1,50 +1,60 @@
<?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">
<!-- Three columns: display and steering wheel | battery and charging price | phone and the app.
Sized to fit the car's 1778x640 dp window without scrolling. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" 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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" 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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
</com.google.android.material.textfield.TextInputLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout 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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" 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"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" 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="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/settings_save_soh_value"/>
</LinearLayout>
<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"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" 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="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/btn_save_capacity"/>
</LinearLayout>
<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:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" 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="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/settings_save_price"/>
</LinearLayout>
</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" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_phone_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<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="12dp" />
<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">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<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" />
@@ -26,6 +26,7 @@
<string name="design_battery_settings_title">BATTERY</string>
<string name="design_app_title">APP</string>
<string name="design_price_title">CHARGING PRICE</string>
<string name="design_phone_title">ON YOUR PHONE</string>
<string name="design_service_timing">UPDATE FREQUENCY</string>
<string name="design_car_desc">Tire pressure positions on the vehicle</string>
</resources>
+2 -2
View File
@@ -29,8 +29,8 @@ spec:
type: RuntimeDefault
containers:
- name: server
# 2.6.0, pinned by digest so a re-pushed tag cannot change what runs.
image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33
# 2.6.1, pinned by digest so a re-pushed tag cannot change what runs.
image: git.stinnesbeck.com/nils/mg4-server@sha256:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd
ports:
- name: http
containerPort: 8080
Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

+2 -2
View File
@@ -111,7 +111,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
<form method="post" action="/settings" autocomplete="off">
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
{{else}}<label for="code">Code shown on the car</label>
<input id="code" name="code" autocapitalize="characters" placeholder="XXX-XXX" required>{{end}}
` + codeInput + `{{end}}
<label for="abrpApiKey">ABRP API key</label>
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
<label for="abrpToken">ABRP user token</label>
@@ -133,7 +133,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("Content-Security-Policy", pageCSP)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
+26 -4
View File
@@ -1,6 +1,8 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"errors"
"html/template"
"io"
@@ -147,7 +149,7 @@ type pageData struct {
Done bool
}
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red, no scripts.
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red.
const pageStyle = `
:root{color-scheme:dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
@@ -160,8 +162,29 @@ input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1p
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
.error{color:#FFB4AB}.ok{color:#A4DCC1}
#code{text-transform:uppercase;letter-spacing:2px;font-family:ui-monospace,monospace}
`
// codeInput is the typed-code field of both phone pages. Its script upper-cases what is typed and
// puts the dash in after the third character; without the script the server still accepts the
// code in any case, with or without the dash (normalizeUserCode).
const codeInput = `<input id="code" name="code" autocapitalize="characters" autocomplete="off" autocorrect="off" spellcheck="false" maxlength="7" placeholder="XXX-XXX" required>
<script>` + codeScript + `</script>`
const codeScript = `document.getElementById('code').addEventListener('input',function(e){` +
`var v=this.value.toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,6);` +
`var del=e.inputType&&e.inputType.indexOf('delete')===0;` +
`this.value=v.length>3||(v.length===3&&!del)?v.slice(0,3)+'-'+v.slice(3):v;});`
// pageCSP allows no script but codeScript, pinned by its hash, and forms only back to this server.
var pageCSP = "default-src 'none'; script-src 'sha256-" + scriptHash(codeScript) +
"'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
func scriptHash(script string) string {
sum := sha256.Sum256([]byte(script))
return base64.StdEncoding.EncodeToString(sum[:])
}
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pair a car — Nils' MG4</title>
@@ -174,7 +197,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
<form method="post" action="/pair" autocomplete="on">
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
{{else}}<label for="code">Code shown on the car</label>
<input id="code" name="code" autocapitalize="characters" autocomplete="off" placeholder="XXX-XXX" required>{{end}}
` + codeInput + `{{end}}
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
<label for="password">Approval password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required {{if .Code}}autofocus{{end}}>
@@ -188,8 +211,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
func renderPage(w http.ResponseWriter, status int, data pageData) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
// No scripts at all, no framing, forms only back to this page.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("Content-Security-Policy", pageCSP)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
+27
View File
@@ -329,3 +329,30 @@ func TestClientAddressTakesTheProxysEntry(t *testing.T) {
t.Fatalf("without a proxy: %q", got)
}
}
// The typed-code field's script is the only one the pages may run: the CSP pins it by hash, so
// the script served must hash to exactly what the header allows.
func TestCodeScriptMatchesTheCSP(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
script := regexp.MustCompile(`(?s)<script>(.*?)</script>`)
for _, path := range []string{"/pair", "/settings"} {
page := do(t, h, "GET", path, "", "")
if page.Code != 200 {
t.Fatalf("%s: %d", path, page.Code)
}
m := script.FindAllStringSubmatch(page.Body.String(), -1)
if len(m) != 1 || m[0][1] != codeScript {
t.Fatalf("%s: scripts %q", path, m)
}
csp := page.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'sha256-"+scriptHash(m[0][1])+"'") || !strings.Contains(csp, "default-src 'none'") {
t.Fatalf("%s: CSP %q", path, csp)
}
}
// With the code in the link there is nothing to type, and no script.
_, userCode, _ := startPairing(t, h)
if body := do(t, h, "GET", "/pair?code="+userCode, "", "").Body.String(); strings.Contains(body, "<script") {
t.Fatal("script on the page with the code filled in")
}
}