Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bae56bec2f | ||
|
|
31ea4e7d78 | ||
|
|
496fb821c3 | ||
|
|
dfd8569cfa |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 141 KiB |
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||