Merge english-translation: Nils' MG4 (app 2.18.0, server 2.6.0)

Turns the FatihsMG4 fork into Nils' MG4: an English Android Automotive
app for the MG4 head unit, a Go car server with InfluxDB 2 and Grafana,
Kubernetes manifests, and updates over the air or from a USB stick.
AGENTS.md is the overview, SPECS.md the journal of every change.

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

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

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

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

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

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

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

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