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:
@@ -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 +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 +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}}]
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_clientMiddlewareManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pagesChunkGroupBootstrapParams": {}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
|
||||
@@ -1 +0,0 @@
|
||||
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
|
||||
@@ -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 +0,0 @@
|
||||
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
|
||||
@@ -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."}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": []
|
||||
}
|
||||
@@ -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 +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 +0,0 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"app": {},
|
||||
"appUsingSizeAdjust": false,
|
||||
"pages": {},
|
||||
"pagesUsingSizeAdjust": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -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()
|
||||
Vendored
-55
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
[](https://github.com/fatihdonmezdev/FatihsMG4/releases)
|
||||
[](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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
<!-- Required from API 33 to post the foreground-service notification, which is the
|
||||
only status UI the service has. Without it the notification is silently dropped. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- GPS for the position in live telemetry. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
||||
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
||||
reading which one it landed on. No password is ever handled here — see
|
||||
WifiAutoConnect for why this works on this Android 9 unit and not on a newer one. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
|
||||
<!-- Android Automotive / CarPropertyManager permissions -->
|
||||
<uses-permission android:name="android.car.permission.CAR_SPEED" />
|
||||
@@ -52,7 +51,8 @@
|
||||
|
||||
<service
|
||||
android:name=".AbrpUploadService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|location" />
|
||||
|
||||
<!-- The head unit resumes far more often than it cold-boots, and announces the
|
||||
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* ABRP's telemetry API, for sending live data straight from the car.
|
||||
*
|
||||
* Credentials go in the POST body, never in the query string: a URL travels through proxies,
|
||||
* access logs and crash reports, a body does not. Neither is ever logged.
|
||||
*/
|
||||
final class AbrpApi {
|
||||
|
||||
/** No query string: credentials travel in the body, so the URL is a constant. */
|
||||
static final String SEND_URL = "https://api.iternio.com/1/tlm/send";
|
||||
|
||||
/**
|
||||
* Read-only endpoint used to check credentials. The Test button must never touch
|
||||
* /tlm/send: a placeholder sample would tell ABRP the car really is in that state and
|
||||
* corrupt a live route plan.
|
||||
*/
|
||||
static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
|
||||
|
||||
private AbrpApi() { }
|
||||
|
||||
/** Posts one tlm sample. Throws on a network failure. */
|
||||
static ServerHttp.Response send(AbrpConfig config, String tlmJson) throws Exception {
|
||||
byte[] body = formBody(config.apiKey, config.userToken, tlmJson).getBytes(StandardCharsets.UTF_8);
|
||||
return ServerHttp.send("POST", SEND_URL, null, "application/x-www-form-urlencoded", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the credentials without writing anything. This endpoint only takes the token as a
|
||||
* query parameter; the API key goes in the Authorization header. One token in one URL when
|
||||
* the user presses Test beats writing fake telemetry on every press.
|
||||
*/
|
||||
static ServerHttp.Response verify(AbrpConfig config) throws Exception {
|
||||
String url = VERIFY_URL + "?token=" + URLEncoder.encode(config.userToken, StandardCharsets.UTF_8.name());
|
||||
return ServerHttp.send("GET", url, "APIKEY " + config.apiKey, null, null);
|
||||
}
|
||||
|
||||
/** The form body: credentials and the sample, each URL-encoded. */
|
||||
static String formBody(String apiKey, String userToken, String tlmJson) throws Exception {
|
||||
String utf8 = StandardCharsets.UTF_8.name();
|
||||
return "api_key=" + URLEncoder.encode(apiKey, utf8)
|
||||
+ "&token=" + URLEncoder.encode(userToken, utf8)
|
||||
+ "&tlm=" + URLEncoder.encode(tlmJson, utf8);
|
||||
}
|
||||
|
||||
/** ABRP answers 200 with {"status":"ok"}; anything else is a rejection. */
|
||||
static boolean accepted(ServerHttp.Response response) {
|
||||
if (!response.ok()) return false;
|
||||
try {
|
||||
return "ok".equals(new org.json.JSONObject(response.body).optString("status"));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* The ABRP credentials for sending live data straight from the car: the API key (which app is
|
||||
* sending) and the user token (which car in which ABRP account). Kept in {@link SecurePrefs}
|
||||
* next to the server settings, entered on the Live data tab or imported from a config file.
|
||||
*
|
||||
* The car is the only one that sends to ABRP: the server stores live data but does not forward
|
||||
* it. Without these, ABRP gets no live data.
|
||||
*/
|
||||
final class AbrpConfig {
|
||||
|
||||
static final String KEY_API_KEY = "abrp_api_key";
|
||||
static final String KEY_USER_TOKEN = "abrp_user_token";
|
||||
|
||||
final String apiKey;
|
||||
final String userToken;
|
||||
|
||||
private static volatile AbrpConfig current;
|
||||
|
||||
AbrpConfig(String apiKey, String userToken) {
|
||||
this.apiKey = apiKey;
|
||||
this.userToken = userToken;
|
||||
}
|
||||
|
||||
boolean isConfigured() {
|
||||
return !apiKey.isEmpty() && !userToken.isEmpty();
|
||||
}
|
||||
|
||||
static AbrpConfig get(Context context) {
|
||||
AbrpConfig config = current;
|
||||
if (config != null) return config;
|
||||
synchronized (AbrpConfig.class) {
|
||||
if (current == null) {
|
||||
SharedPreferences prefs = SecurePrefs.get(context);
|
||||
current = new AbrpConfig(prefs.getString(KEY_API_KEY, ""), prefs.getString(KEY_USER_TOKEN, ""));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores trimmed values; empty strings switch direct sending off. */
|
||||
static synchronized void save(Context context, String apiKey, String userToken) {
|
||||
String key = apiKey == null ? "" : apiKey.trim();
|
||||
String token = userToken == null ? "" : userToken.trim();
|
||||
SecurePrefs.get(context).edit()
|
||||
.putString(KEY_API_KEY, key)
|
||||
.putString(KEY_USER_TOKEN, token)
|
||||
.apply();
|
||||
current = new AbrpConfig(key, token);
|
||||
}
|
||||
|
||||
/** Plausible as an ABRP credential: no spaces, not absurdly short or long. */
|
||||
static boolean isPlausible(String value) {
|
||||
if (value == null) return false;
|
||||
String v = value.trim();
|
||||
return v.length() >= 8 && v.length() <= 200 && !v.contains(" ");
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
|
||||
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything to ABRP.
|
||||
* syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that
|
||||
* the server stores and, with ABRP credentials set, that go straight to ABRP
|
||||
* ({@link TelemetryUploader}).
|
||||
*
|
||||
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
|
||||
* credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
|
||||
* missing one is handled by caching chunks locally until the connection returns.
|
||||
* credentials or a toggle. The only thing it needs is a network, and a missing one is
|
||||
* handled by queueing locally until the connection returns.
|
||||
*/
|
||||
public class AbrpUploadService extends Service {
|
||||
|
||||
@@ -64,7 +66,8 @@ public class AbrpUploadService extends Service {
|
||||
private ConsumptionTracker consumptionTracker;
|
||||
private ConsumptionCloudClient consumptionCloudClient;
|
||||
private ChargeSessionTracker chargeSessionTracker;
|
||||
private WifiAutoConnect wifi;
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private SteeringKeyListener steeringKeys;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
@@ -93,14 +96,16 @@ public class AbrpUploadService extends Service {
|
||||
consumptionTracker = ConsumptionTracker.get(this);
|
||||
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
||||
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
||||
wifi = new WifiAutoConnect(getApplicationContext());
|
||||
telemetryUploader = new TelemetryUploader(getApplicationContext());
|
||||
steeringKeys = new SteeringKeyListener(this, prefs);
|
||||
steeringKeys.register();
|
||||
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::safeUploadCycle,
|
||||
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
|
||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
|
||||
"Tüketim servisi başlatıldı"));
|
||||
"Consumption service started"));
|
||||
Log.i(TAG, "Service started, consumption tracking armed");
|
||||
}
|
||||
|
||||
@@ -111,15 +116,16 @@ public class AbrpUploadService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
|
||||
* service types do not exist before API 29, so on this API-28 head unit a plain
|
||||
* startForeground is the only correct call.
|
||||
* Enters the foreground. Foreground service types do not exist before API 29, so on this
|
||||
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
|
||||
* location type is what lets the GPS subscription run in the background.
|
||||
*/
|
||||
private void startInForeground() {
|
||||
Notification notification = buildNotification(lastStatus);
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
ServiceCompat.startForeground(
|
||||
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
this, NOTIF_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC | ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification);
|
||||
}
|
||||
@@ -133,6 +139,11 @@ public class AbrpUploadService extends Service {
|
||||
running = false;
|
||||
prefs.edit().putBoolean("service_running", false).apply();
|
||||
if (scheduler != null) scheduler.shutdownNow();
|
||||
if (telemetryUploader != null) {
|
||||
telemetryUploader.persist(); // what is still queued survives the restart
|
||||
telemetryUploader.stop();
|
||||
}
|
||||
if (steeringKeys != null) steeringKeys.unregister();
|
||||
Log.i(TAG, "Service stopped");
|
||||
}
|
||||
|
||||
@@ -149,17 +160,27 @@ public class AbrpUploadService extends Service {
|
||||
private void doUpload() {
|
||||
long sampleMs = System.currentTimeMillis();
|
||||
EnergySnapshot vehicle = energyReader.read(sampleMs);
|
||||
// Keeps the 5-minute range average going while the app is in the background.
|
||||
RangeEstimator.shared().add(android.os.SystemClock.elapsedRealtime(),
|
||||
vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
|
||||
consumptionTracker.sample(vehicle);
|
||||
chargeSessionTracker.sample(vehicle);
|
||||
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
||||
|
||||
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
|
||||
// exactly when being offline costs something. Rate-limited internally.
|
||||
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
||||
wifi.ensureConnected(false);
|
||||
UploadSettings settings = UploadSettings.from(prefs);
|
||||
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
|
||||
// nowhere to send it.
|
||||
if (!ServerConfig.get(this).isConfigured() && !AbrpConfig.get(this).isConfigured()) {
|
||||
telemetryUploader.stop(); // no live telemetry, so no GPS either
|
||||
updateNotification(getString(R.string.server_not_configured));
|
||||
} else if (!settings.abrpUploadEnabled) {
|
||||
telemetryUploader.stop();
|
||||
updateNotification(getString(R.string.notif_tracking_locally));
|
||||
} else {
|
||||
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
|
||||
int queued = telemetryUploader.queued();
|
||||
updateNotification(queued > 0 ? "Sending telemetry — " + queued + " queued" : "Sending telemetry");
|
||||
}
|
||||
|
||||
updateNotification("Tüketim takibi aktif");
|
||||
}
|
||||
|
||||
// ---------- Notification ----------
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.TypedValue;
|
||||
|
||||
/**
|
||||
* The app's accent colour, after the MG4 paint colours; Dynamic Red unless another is chosen
|
||||
* (Settings → Display). Each is a theme overlay in res/values/accents.xml that MainActivity lays
|
||||
* over the app theme before it inflates anything; a change recreates the activity.
|
||||
*/
|
||||
enum AccentColor {
|
||||
DYNAMIC_RED("Dynamic Red", R.style.ThemeOverlay_EV_Accent_DynamicRed),
|
||||
VOLCANO_ORANGE("Volcano Orange", R.style.ThemeOverlay_EV_Accent_VolcanoOrange),
|
||||
HOLBORN_BLUE("Holborn Blue", R.style.ThemeOverlay_EV_Accent_HolbornBlue),
|
||||
RACING_GREEN("Racing Green", R.style.ThemeOverlay_EV_Accent_RacingGreen),
|
||||
CAMDEN_GREY("Camden Grey", R.style.ThemeOverlay_EV_Accent_CamdenGrey),
|
||||
MONUMENT_SILVER("Monument Silver", R.style.ThemeOverlay_EV_Accent_MonumentSilver),
|
||||
ARCTIC_WHITE("Arctic White", R.style.ThemeOverlay_EV_Accent_ArcticWhite),
|
||||
BLACK_PEARL("Black Pearl", R.style.ThemeOverlay_EV_Accent_BlackPearl);
|
||||
|
||||
static final String PREF_KEY = "accent_color";
|
||||
static final AccentColor DEFAULT = DYNAMIC_RED;
|
||||
|
||||
final String label;
|
||||
final int overlay;
|
||||
|
||||
AccentColor(String label, int overlay) {
|
||||
this.label = label;
|
||||
this.overlay = overlay;
|
||||
}
|
||||
|
||||
/** The stored choice; anything unknown falls back to Dynamic Red. */
|
||||
static AccentColor fromPref(String name) {
|
||||
for (AccentColor c : values()) {
|
||||
if (c.name().equals(name)) return c;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
/** A colour of the current accent palette, e.g. {@code R.attr.evAccent}, for code that draws. */
|
||||
static int resolve(Context context, int attr) {
|
||||
TypedValue value = new TypedValue();
|
||||
context.getTheme().resolveAttribute(attr, value, true);
|
||||
return value.data;
|
||||
}
|
||||
|
||||
/** The colour with its alpha replaced, for glows and grids drawn in code. */
|
||||
static int withAlpha(int color, int alpha) {
|
||||
return (color & 0x00FFFFFF) | (alpha << 24);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public final class ChargingGraphView extends View {
|
||||
private final Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1);
|
||||
private final List<float[]> points = new ArrayList<>();
|
||||
private final float[] socPower = new float[101];
|
||||
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x5548C7D9); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
|
||||
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(AccentColor.withAlpha(AccentColor.resolve(c, R.attr.evAccentLight), 0x55)); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
|
||||
synchronized void add(Float soc, Float power) {
|
||||
if (soc == null || power == null || power >= 0) return;
|
||||
float p = Math.abs(power);
|
||||
@@ -33,13 +33,13 @@ public final class ChargingGraphView extends View {
|
||||
invalidate();
|
||||
}
|
||||
synchronized String tableText() {
|
||||
StringBuilder out = new StringBuilder("SOC Şarj gücü\n");
|
||||
StringBuilder out = new StringBuilder("SOC Charge power\n");
|
||||
boolean found = false;
|
||||
for (int soc=0; soc<=100; soc++) if (!Float.isNaN(socPower[soc])) {
|
||||
found = true;
|
||||
out.append(String.format(Locale.getDefault(), "%3d %% %6.1f kW\n", soc, socPower[soc]));
|
||||
}
|
||||
return found ? out.toString().trim() : "Henüz şarj ölçümü yok";
|
||||
return found ? out.toString().trim() : "No charging measurements yet";
|
||||
}
|
||||
@Override protected synchronized void onDraw(Canvas c) {
|
||||
super.onDraw(c); float left=58, right=getWidth()-12, top=12, bottom=getHeight()-38;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Parses a configuration text file so the app can be set up without typing on the car's
|
||||
* on-screen keyboard — the ingest token in particular is long and error-prone to enter by
|
||||
* hand. The file is a plain {@code key = value} list, one entry per line:
|
||||
*
|
||||
* <pre>
|
||||
* # Nils' MG4 config
|
||||
* server_url = https://car.k8s.stinnesbeck.com
|
||||
* token = 0123abcd... (the server's INGEST_TOKEN)
|
||||
* installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b
|
||||
* interval_sec = 60
|
||||
* boost_low_soc = true
|
||||
* low_soc_percent = 20
|
||||
* abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly)
|
||||
* abrp_token = your-abrp-user-token
|
||||
* charging_price = 0.39 (€/kWh)
|
||||
* usable_kwh = 50.8 (usable battery capacity, for the range estimates)
|
||||
* </pre>
|
||||
*
|
||||
* Usually only server_url and token are needed. installation_id is for a reinstall: it puts
|
||||
* the car back under the id its history is stored with. The cadence keys are optional. Blank lines and
|
||||
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
|
||||
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
|
||||
* leaving anything absent untouched.
|
||||
*/
|
||||
final class ConfigImport {
|
||||
|
||||
final String serverUrl;
|
||||
final String token;
|
||||
final String installationId;
|
||||
final Integer intervalSec;
|
||||
final Boolean boostLowSoc;
|
||||
final Integer lowSocPercent;
|
||||
final String abrpApiKey;
|
||||
final String abrpToken;
|
||||
final Double chargingPrice;
|
||||
final Double usableKwh;
|
||||
|
||||
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
|
||||
Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken,
|
||||
Double chargingPrice, Double usableKwh) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.token = token;
|
||||
this.installationId = installationId;
|
||||
this.intervalSec = intervalSec;
|
||||
this.boostLowSoc = boostLowSoc;
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
this.abrpApiKey = abrpApiKey;
|
||||
this.abrpToken = abrpToken;
|
||||
this.chargingPrice = chargingPrice;
|
||||
this.usableKwh = usableKwh;
|
||||
}
|
||||
|
||||
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
||||
boolean isEmpty() {
|
||||
return serverUrl == null && token == null && installationId == null && intervalSec == null
|
||||
&& boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null
|
||||
&& chargingPrice == null && usableKwh == null;
|
||||
}
|
||||
|
||||
static ConfigImport parse(String text) {
|
||||
String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null;
|
||||
Integer intervalSec = null, lowSocPercent = null;
|
||||
Boolean boostLowSoc = null;
|
||||
Double chargingPrice = null, usableKwh = null;
|
||||
|
||||
for (String line : text.split("\n", -1)) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.charAt(0) == '#') continue;
|
||||
|
||||
int sep = line.indexOf('=');
|
||||
if (sep <= 0) continue;
|
||||
|
||||
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
|
||||
String value = line.substring(sep + 1).trim();
|
||||
if (value.isEmpty()) continue;
|
||||
|
||||
switch (key) {
|
||||
case "server_url":
|
||||
case "server":
|
||||
case "url":
|
||||
serverUrl = value;
|
||||
break;
|
||||
case "token":
|
||||
case "ingest_token":
|
||||
token = value;
|
||||
break;
|
||||
case "installation_id":
|
||||
installationId = value;
|
||||
break;
|
||||
case "interval_sec":
|
||||
case "upload_interval_sec":
|
||||
intervalSec = nearestInterval(value);
|
||||
break;
|
||||
case "boost_low_soc":
|
||||
boostLowSoc = parseBool(value);
|
||||
break;
|
||||
case "low_soc_percent":
|
||||
lowSocPercent = clampPercent(value);
|
||||
break;
|
||||
case "abrp_api_key":
|
||||
abrpApiKey = value;
|
||||
break;
|
||||
case "abrp_token":
|
||||
case "abrp_user_token":
|
||||
abrpToken = value;
|
||||
break;
|
||||
case "charging_price":
|
||||
case "price_per_kwh":
|
||||
chargingPrice = parseDecimal(value);
|
||||
break;
|
||||
case "usable_kwh":
|
||||
case "usable_capacity_kwh":
|
||||
usableKwh = parseDecimal(value);
|
||||
break;
|
||||
default:
|
||||
// Unknown keys are ignored rather than rejected, so a file can carry
|
||||
// comments or future keys without breaking an older build.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent,
|
||||
abrpApiKey, abrpToken, chargingPrice, usableKwh);
|
||||
}
|
||||
|
||||
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
|
||||
private static Integer nearestInterval(String value) {
|
||||
int requested;
|
||||
try {
|
||||
requested = Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
|
||||
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
|
||||
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
|
||||
private static Integer clampPercent(String value) {
|
||||
try {
|
||||
return Math.max(1, Math.min(99, Integer.parseInt(value)));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A decimal with a dot or a comma; null when it is not a number. Ranges are checked where
|
||||
* the value is saved, the same as for the fields on screen.
|
||||
*/
|
||||
private static Double parseDecimal(String value) {
|
||||
try {
|
||||
double d = Double.parseDouble(value.replace(',', '.'));
|
||||
return Double.isFinite(d) ? d : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean parseBool(String value) {
|
||||
value = value.toLowerCase(java.util.Locale.US);
|
||||
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
|
||||
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,39 +7,31 @@ import android.util.Log;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
|
||||
* Best-effort sync to the car server. Never blocks local persistence.
|
||||
*
|
||||
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
|
||||
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
|
||||
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
|
||||
* picks up right where the old one left off instead of showing zeroes.
|
||||
* Server URL, token and installation id come from {@link ServerConfig}, read on every use so
|
||||
* a change in the Server tab applies at once. The installation id is kept across updates, and
|
||||
* a reinstall derives the same one from the head unit ({@link ServerConfig#installationIdFor})
|
||||
* or gets it back through the config file, so the history is not orphaned under a new
|
||||
* identity. The consumption UI reads back its own uploaded totals via
|
||||
* {@link #fetchHistoryIfDue}, so a reinstall picks up where the old one left off.
|
||||
*/
|
||||
final class ConsumptionCloudClient {
|
||||
private static final String TAG = "FatihsMG4.Mongo";
|
||||
private static final String TAG = "NilsMG4.Mongo";
|
||||
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L;
|
||||
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
|
||||
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
|
||||
private static final int TIMEOUT_MS = 8_000;
|
||||
private static final String PREFS = "consumption_cloud";
|
||||
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
|
||||
private static final int MAX_CHARGES_PER_SYNC = 10;
|
||||
|
||||
private final String endpoint;
|
||||
private final String token;
|
||||
private final String installationId;
|
||||
private final Context context;
|
||||
private final SharedPreferences prefs;
|
||||
private long lastConsumptionAttemptMs;
|
||||
private long lastChargeAttemptMs;
|
||||
@@ -65,21 +57,19 @@ final class ConsumptionCloudClient {
|
||||
}
|
||||
|
||||
ConsumptionCloudClient(Context context) {
|
||||
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
|
||||
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
|
||||
this.context = context.getApplicationContext();
|
||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
|
||||
if (endpoint.isEmpty() || token.isEmpty()) {
|
||||
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
|
||||
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
|
||||
}
|
||||
}
|
||||
|
||||
private ServerConfig config() {
|
||||
return ServerConfig.get(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
|
||||
* to send (offline, server error) accumulate in a local cache and are retried on the
|
||||
* next cycle — so a drive through a dead-signal area still reaches the cloud once the
|
||||
* head unit is back on the hotspot. Every attempt is logged to the in-app "Kayıtlar" tab.
|
||||
* head unit is back on the hotspot. Every attempt is logged to the in-app "Logs" tab.
|
||||
*
|
||||
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
|
||||
* monotonic clock instead, so a backwards NTP correction cannot stall
|
||||
@@ -87,7 +77,7 @@ final class ConsumptionCloudClient {
|
||||
*/
|
||||
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
|
||||
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||
if (endpoint.isEmpty() || token.isEmpty()) {
|
||||
if (!config().isConfigured()) {
|
||||
Log.i(TAG, "Sync skipped: API not configured");
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +86,7 @@ final class ConsumptionCloudClient {
|
||||
// Drain any cached chunks first — a backlog from an offline period has priority.
|
||||
int cached = drainChunkCache();
|
||||
if (cached > 0) {
|
||||
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
|
||||
logEvent(true, 200, "Cache drained: " + cached + " chunk(s) sent");
|
||||
invalidateHistory();
|
||||
}
|
||||
|
||||
@@ -115,14 +105,14 @@ final class ConsumptionCloudClient {
|
||||
try {
|
||||
JSONObject body = consumptionBody(chunk, nowMs);
|
||||
if (send("/v1/consumption/daily", "PUT", body)) {
|
||||
logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+"
|
||||
logEvent(true, 200, "Chunk " + chunk.date + " sent (+"
|
||||
+ String.format("%.1f", chunk.day.km) + "km, +"
|
||||
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
|
||||
invalidateHistory();
|
||||
} else {
|
||||
cacheChunk(body);
|
||||
int pending = pendingChunkCount();
|
||||
logEvent(false, 0, "Chunk gönderilemedi — cache'de bekliyor (" + pending + " chunk)");
|
||||
logEvent(false, 0, "Chunk not sent — waiting in cache (" + pending + " chunk(s))");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Network failure: cache the chunk for the next cycle.
|
||||
@@ -131,7 +121,7 @@ final class ConsumptionCloudClient {
|
||||
cacheChunk(body);
|
||||
} catch (Exception ignored) { }
|
||||
int pending = pendingChunkCount();
|
||||
logEvent(false, 0, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage());
|
||||
logEvent(false, 0, "Offline — waiting in cache (" + pending + " chunk(s)): " + e.getMessage());
|
||||
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -190,7 +180,7 @@ final class ConsumptionCloudClient {
|
||||
return loadChunkCache().length();
|
||||
}
|
||||
|
||||
/** Records a cloud-sync attempt to the in-app log (the "Kayıtlar" tab). */
|
||||
/** Records a cloud-sync attempt to the in-app log (the "Logs" tab). */
|
||||
private void logEvent(boolean success, int httpStatus, String detail) {
|
||||
UploadLog log = AbrpUploadService.log();
|
||||
if (log == null) return;
|
||||
@@ -200,7 +190,7 @@ final class ConsumptionCloudClient {
|
||||
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
|
||||
throws Exception {
|
||||
JSONObject body = new JSONObject()
|
||||
.put("installationId", installationId)
|
||||
.put("installationId", config().installationId)
|
||||
.put("recordedAt", nowMs)
|
||||
.put("date", snapshot.date)
|
||||
.put("day", totals(snapshot.day));
|
||||
@@ -220,7 +210,7 @@ final class ConsumptionCloudClient {
|
||||
try {
|
||||
syncPendingCharges(charges, pending);
|
||||
} catch (Exception e) {
|
||||
logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage());
|
||||
logEvent(false, 0, "Charging session sync error: " + e.getMessage());
|
||||
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -230,15 +220,15 @@ final class ConsumptionCloudClient {
|
||||
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
|
||||
int sent = 0;
|
||||
for (JSONObject session : pending) {
|
||||
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
|
||||
JSONObject body = new JSONObject(session.toString()).put("installationId", config().installationId);
|
||||
String id = session.getString("sessionId");
|
||||
Log.i(TAG, "Uploading charge session: " + id);
|
||||
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
|
||||
charges.markUploaded(id);
|
||||
logEvent(true, 200, "Şarj session gönderildi: " + id);
|
||||
logEvent(true, 200, "Charging session sent: " + id);
|
||||
Log.i(TAG, "Successfully uploaded charge session: " + id);
|
||||
} else {
|
||||
logEvent(false, 0, "Şarj session gönderilemedi: " + id);
|
||||
logEvent(false, 0, "Charging session not sent: " + id);
|
||||
Log.w(TAG, "Failed to upload charge session: " + id);
|
||||
return;
|
||||
}
|
||||
@@ -247,36 +237,20 @@ final class ConsumptionCloudClient {
|
||||
}
|
||||
|
||||
private boolean send(String path, String method, JSONObject body) throws Exception {
|
||||
HttpURLConnection connection = null;
|
||||
String url = trimSlash(config().url) + path;
|
||||
Log.i(TAG, "Sending " + method + " request to " + url);
|
||||
try {
|
||||
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
String url = trimSlash(endpoint) + path;
|
||||
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
|
||||
Log.i(TAG, "Body: " + body.toString());
|
||||
connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
connection.setDoOutput(true);
|
||||
connection.setFixedLengthStreamingMode(bytes.length);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
|
||||
int status = connection.getResponseCode();
|
||||
Log.i(TAG, "Response: HTTP " + status);
|
||||
if (status < 200 || status >= 300) {
|
||||
String errBody = readErrorBody(connection);
|
||||
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
|
||||
ServerHttp.Response response = ServerHttp.request(method, url, config().token, body);
|
||||
Log.i(TAG, "Response: HTTP " + response.status);
|
||||
if (!response.ok()) {
|
||||
String err = response.excerpt();
|
||||
Log.w(TAG, "Sync rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
|
||||
return false;
|
||||
}
|
||||
InputStream response = connection.getInputStream();
|
||||
if (response != null) try (InputStream ignored = response) { }
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Network error: " + e.getMessage());
|
||||
throw e;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +269,7 @@ final class ConsumptionCloudClient {
|
||||
* Returns the current cache immediately; the fresh copy lands on the next poll.
|
||||
*/
|
||||
synchronized List<DayRecord> fetchHistoryIfDue() {
|
||||
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
|
||||
if (!config().isConfigured()) return cachedHistory;
|
||||
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
|
||||
return cachedHistory;
|
||||
@@ -310,11 +284,11 @@ final class ConsumptionCloudClient {
|
||||
List<DayRecord> fresh = fetchHistory();
|
||||
if (!fresh.isEmpty()) cachedHistory = fresh;
|
||||
Log.i(TAG, "Fetched " + fresh.size() + " history records from cloud");
|
||||
logEvent(true, 200, "History çekildi: " + fresh.size() + " gün");
|
||||
logEvent(true, 200, "History fetched: " + fresh.size() + " day(s)");
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
Log.w(TAG, "History fetch unavailable: " + msg);
|
||||
logEvent(false, 0, "History fetch hatası: " + msg);
|
||||
logEvent(false, 0, "History fetch error: " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,11 +299,11 @@ final class ConsumptionCloudClient {
|
||||
|
||||
/**
|
||||
* Immediately attempts to send every cached chunk and take a fresh one, ignoring the
|
||||
* 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line
|
||||
* 2-minute throttle. Called from the UI "Upload now" button. Returns a one-line
|
||||
* status for the log.
|
||||
*/
|
||||
String flushNow(ConsumptionTracker tracker, long nowMs) {
|
||||
if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış";
|
||||
if (!config().isConfigured()) return "API not configured";
|
||||
int cached = drainChunkCache();
|
||||
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
|
||||
int total = cached;
|
||||
@@ -348,50 +322,39 @@ final class ConsumptionCloudClient {
|
||||
}
|
||||
int pending = pendingChunkCount();
|
||||
String msg = total > 0
|
||||
? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "")
|
||||
: (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok");
|
||||
logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg);
|
||||
? total + " chunk(s) uploaded" + (pending > 0 ? ", " + pending + " still pending" : "")
|
||||
: (pending > 0 ? pending + " chunk(s) could not be uploaded (internet?)" : "No new data to upload");
|
||||
logEvent(total > 0, total > 0 ? 200 : 0, "Manual sync: " + msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
private List<DayRecord> fetchHistory() throws Exception {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
|
||||
connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||
int status = connection.getResponseCode();
|
||||
Log.i(TAG, "History GET response: HTTP " + status);
|
||||
if (status < 200 || status >= 300) {
|
||||
String err = readErrorBody(connection);
|
||||
Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err));
|
||||
logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err));
|
||||
return cachedHistory;
|
||||
}
|
||||
String body = readBody(connection);
|
||||
JSONObject json = new JSONObject(body);
|
||||
JSONArray records = json.optJSONArray("records");
|
||||
if (records == null) return Collections.emptyList();
|
||||
// Lifetime is derived by the backend (sum of all days), sent once at the response
|
||||
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
|
||||
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
|
||||
? parseTotals(json.getJSONObject("lifetime")) : null;
|
||||
List<DayRecord> result = new ArrayList<>(records.length());
|
||||
for (int i = 0; i < records.length(); i++) {
|
||||
JSONObject r = records.getJSONObject(i);
|
||||
LocalDate date = LocalDate.parse(r.getString("date"));
|
||||
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
|
||||
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
|
||||
? (float) r.getDouble("sohPercent") : null;
|
||||
result.add(new DayRecord(date, day, lifetime, soh));
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
|
||||
ServerHttp.Response response = ServerHttp.request("GET", url, config().token, null);
|
||||
Log.i(TAG, "History GET response: HTTP " + response.status);
|
||||
if (!response.ok()) {
|
||||
String err = response.excerpt();
|
||||
Log.w(TAG, "History fetch rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
|
||||
logEvent(false, response.status, "History GET HTTP " + response.status + (err.isEmpty() ? "" : " — " + err));
|
||||
return cachedHistory;
|
||||
}
|
||||
JSONObject json = new JSONObject(response.body);
|
||||
JSONArray records = json.optJSONArray("records");
|
||||
if (records == null) return Collections.emptyList();
|
||||
// Lifetime is derived by the backend (sum of all days), sent once at the response
|
||||
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
|
||||
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
|
||||
? parseTotals(json.getJSONObject("lifetime")) : null;
|
||||
List<DayRecord> result = new ArrayList<>(records.length());
|
||||
for (int i = 0; i < records.length(); i++) {
|
||||
JSONObject r = records.getJSONObject(i);
|
||||
LocalDate date = LocalDate.parse(r.getString("date"));
|
||||
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
|
||||
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
|
||||
? (float) r.getDouble("sohPercent") : null;
|
||||
result.add(new DayRecord(date, day, lifetime, soh));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
|
||||
@@ -400,35 +363,6 @@ final class ConsumptionCloudClient {
|
||||
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection connection) throws Exception {
|
||||
InputStream stream = connection.getInputStream();
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the error response body (truncated) for logging — never throws. */
|
||||
private static String readErrorBody(HttpURLConnection connection) {
|
||||
try {
|
||||
InputStream stream = connection.getErrorStream();
|
||||
if (stream == null) return "";
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
String body = sb.toString();
|
||||
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimSlash(String value) {
|
||||
int end = value.length();
|
||||
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
||||
|
||||
@@ -216,7 +216,7 @@ final class ConsumptionTracker {
|
||||
if (cloudLifetime.km > local.km) {
|
||||
lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc);
|
||||
persist();
|
||||
Log.i("FatihsMG4.Consumption", "Lifetime baseline synced from cloud: "
|
||||
Log.i("NilsMG4.Consumption", "Lifetime baseline synced from cloud: "
|
||||
+ cloudLifetime.km + " km (was " + local.km + " km)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Spots a double press of one steering-wheel key from the head unit's key broadcasts.
|
||||
*
|
||||
* The broadcast format is not documented, so this copes with all the shapes it could take:
|
||||
* - no up/down information at all: every broadcast is one press;
|
||||
* - separate down and up broadcasts: only releases count. If the meaning of the action field
|
||||
* is the other way round, downs count instead — still exactly one per press, so a double
|
||||
* press is found either way;
|
||||
* - a long press resets, so holding the key never opens anything.
|
||||
*
|
||||
* Android-free and unit-tested; the window is longer than the stock 300 ms, because a
|
||||
* deliberate double press on a steering wheel is slower than one on a touch screen.
|
||||
*/
|
||||
final class DoublePressDetector {
|
||||
|
||||
static final long WINDOW_MS = 500;
|
||||
|
||||
private final int keyCode;
|
||||
private long lastPressAt = Long.MIN_VALUE;
|
||||
|
||||
DoublePressDetector(int keyCode) {
|
||||
this.keyCode = keyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param down true for a key-down, false for a release, null when the broadcast does not say
|
||||
* @param longPress true when the broadcast marks a long press
|
||||
* @return true exactly once per double press
|
||||
*/
|
||||
synchronized boolean accept(int code, Boolean down, boolean longPress, long atMillis) {
|
||||
if (code != keyCode) return false;
|
||||
if (longPress) {
|
||||
lastPressAt = Long.MIN_VALUE;
|
||||
return false;
|
||||
}
|
||||
if (Boolean.TRUE.equals(down)) return false;
|
||||
if (lastPressAt != Long.MIN_VALUE && atMillis - lastPressAt <= WINDOW_MS) {
|
||||
lastPressAt = Long.MIN_VALUE; // a third press starts over
|
||||
return true;
|
||||
}
|
||||
lastPressAt = atMillis;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* The car's side of pairing with the server (an OAuth device-authorization flow): ask for a
|
||||
* pairing request, show its code as a QR code, then poll until a phone has approved it and
|
||||
* collect this car's own token. Blocking calls — run them off the main thread.
|
||||
*/
|
||||
final class PairingClient {
|
||||
|
||||
/** A pairing request as the server opened it. */
|
||||
static final class Request {
|
||||
final String deviceCode;
|
||||
final String userCode;
|
||||
final String verificationUri;
|
||||
final String verificationUriComplete;
|
||||
final long expiresAtMs;
|
||||
final long intervalMs;
|
||||
|
||||
Request(String deviceCode, String userCode, String verificationUri, String verificationUriComplete,
|
||||
long expiresAtMs, long intervalMs) {
|
||||
this.deviceCode = deviceCode;
|
||||
this.userCode = userCode;
|
||||
this.verificationUri = verificationUri;
|
||||
this.verificationUriComplete = verificationUriComplete;
|
||||
this.expiresAtMs = expiresAtMs;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
enum Outcome { PENDING, APPROVED, DENIED, EXPIRED }
|
||||
|
||||
static final class PollResult {
|
||||
final Outcome outcome;
|
||||
final String token;
|
||||
|
||||
PollResult(Outcome outcome, String token) {
|
||||
this.outcome = outcome;
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
|
||||
static final class NotEnabledException extends Exception { }
|
||||
|
||||
/** The server answered with a status the flow does not expect. */
|
||||
static final class HttpStatusException extends java.io.IOException {
|
||||
final int status;
|
||||
|
||||
HttpStatusException(int status) {
|
||||
super("HTTP " + status);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the server says when APPROVAL_PASSWORD is unset; any other 404 is a missing route. */
|
||||
static final String NOT_ENABLED_MESSAGE = "pairing is not enabled";
|
||||
|
||||
private PairingClient() { }
|
||||
|
||||
static Request start(String serverUrl, String installationId) throws Exception {
|
||||
ServerHttp.Response r = post(serverUrl + "/v1/pair/start",
|
||||
new JSONObject().put("installationId", installationId));
|
||||
// Only the server's own answer means "switched off". A 404 without it is a server (or
|
||||
// proxy) that has no pairing route at all, e.g. one older than 2.1.0.
|
||||
if (r.status == 404 && r.body.contains(NOT_ENABLED_MESSAGE)) throw new NotEnabledException();
|
||||
if (r.status != 201) throw new HttpStatusException(r.status);
|
||||
JSONObject json = new JSONObject(r.body);
|
||||
long now = System.currentTimeMillis();
|
||||
return new Request(
|
||||
json.getString("deviceCode"),
|
||||
json.getString("userCode"),
|
||||
json.getString("verificationUri"),
|
||||
json.getString("verificationUriComplete"),
|
||||
now + json.optLong("expiresIn", 600) * 1000L,
|
||||
Math.max(2, json.optLong("interval", 5)) * 1000L);
|
||||
}
|
||||
|
||||
static PollResult poll(String serverUrl, String deviceCode) throws Exception {
|
||||
ServerHttp.Response r = post(serverUrl + "/v1/pair/poll", new JSONObject().put("deviceCode", deviceCode));
|
||||
switch (r.status) {
|
||||
case 200: return new PollResult(Outcome.APPROVED, new JSONObject(r.body).getString("token"));
|
||||
case 202: return new PollResult(Outcome.PENDING, null);
|
||||
case 403: return new PollResult(Outcome.DENIED, null);
|
||||
case 410: return new PollResult(Outcome.EXPIRED, null);
|
||||
default: throw new HttpStatusException(r.status);
|
||||
}
|
||||
}
|
||||
|
||||
private static ServerHttp.Response post(String url, JSONObject json) throws Exception {
|
||||
return ServerHttp.request("POST", url, null, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* The car's side of entering settings on a phone: open a request on the server (with this
|
||||
* car's token), show it as a QR code, then poll until the phone has sent the values, which
|
||||
* the server hands out once. Same codes and QR flow as pairing ({@link PairingClient}).
|
||||
* Blocking calls — run them off the main thread.
|
||||
*/
|
||||
final class PhoneSettingsClient {
|
||||
|
||||
/** What the phone sent; null fields were left empty there. */
|
||||
static final class Settings {
|
||||
final String abrpApiKey;
|
||||
final String abrpToken;
|
||||
final Double pricePerKwh;
|
||||
final Double usableKwh;
|
||||
|
||||
Settings(String abrpApiKey, String abrpToken, Double pricePerKwh, Double usableKwh) {
|
||||
this.abrpApiKey = abrpApiKey;
|
||||
this.abrpToken = abrpToken;
|
||||
this.pricePerKwh = pricePerKwh;
|
||||
this.usableKwh = usableKwh;
|
||||
}
|
||||
|
||||
static Settings parse(String body) throws Exception {
|
||||
JSONObject json = new JSONObject(body);
|
||||
return new Settings(text(json, "abrpApiKey"), text(json, "abrpToken"),
|
||||
json.has("pricePerKwh") ? json.getDouble("pricePerKwh") : null,
|
||||
json.has("usableKwh") ? json.getDouble("usableKwh") : null);
|
||||
}
|
||||
|
||||
private static String text(JSONObject json, String key) {
|
||||
String value = json.optString(key, "").trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
private PhoneSettingsClient() { }
|
||||
|
||||
static PairingClient.Request start(ServerConfig config) throws Exception {
|
||||
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/start", config.token,
|
||||
new JSONObject().put("installationId", config.installationId));
|
||||
if (r.status == 404) throw new PairingClient.NotEnabledException();
|
||||
if (r.status != 201) throw new PairingClient.HttpStatusException(r.status);
|
||||
JSONObject json = new JSONObject(r.body);
|
||||
long now = System.currentTimeMillis();
|
||||
return new PairingClient.Request(
|
||||
json.getString("deviceCode"),
|
||||
json.getString("userCode"),
|
||||
json.getString("verificationUri"),
|
||||
json.getString("verificationUriComplete"),
|
||||
now + json.optLong("expiresIn", 600) * 1000L,
|
||||
Math.max(2, json.optLong("interval", 5)) * 1000L);
|
||||
}
|
||||
|
||||
/** Null while the phone has not sent anything; throws once the request has expired (410). */
|
||||
static Settings poll(ServerConfig config, String deviceCode) throws Exception {
|
||||
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/poll", config.token,
|
||||
new JSONObject().put("deviceCode", deviceCode));
|
||||
if (r.status == 202) return null;
|
||||
if (r.status == 200) return Settings.parse(r.body);
|
||||
throw new PairingClient.HttpStatusException(r.status);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import android.graphics.Shader;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */
|
||||
/**
|
||||
* Static, low-opacity light behind the glass panels, in the accent colour; no blur passes or
|
||||
* animation loop.
|
||||
*/
|
||||
public final class PremiumBackdropView extends View {
|
||||
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
@@ -20,10 +23,12 @@ public final class PremiumBackdropView extends View {
|
||||
|
||||
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
float radius = Math.max(1, Math.max(w, h) * 0.65f);
|
||||
int deep = AccentColor.resolve(getContext(), R.attr.evAccentDeep);
|
||||
int stroke = AccentColor.resolve(getContext(), R.attr.evAccentStroke);
|
||||
cyan.setShader(new RadialGradient(w * 0.70f, 0, radius,
|
||||
new int[]{0x2419565B, 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
new int[]{AccentColor.withAlpha(deep, 0x48), 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
gold.setShader(new RadialGradient(w, h, radius * 0.65f,
|
||||
new int[]{0x1C8B693A, 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
new int[]{AccentColor.withAlpha(stroke, 0x1C), 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
}
|
||||
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The unit tyre pressures are shown in. The car reports kPa; this only changes the display.
|
||||
* Android-free, unit-tested.
|
||||
*/
|
||||
enum PressureUnit {
|
||||
BAR("bar", "%.1f bar", 0.01),
|
||||
KPA("kPa", "%.0f kPa", 1.0),
|
||||
PSI("psi", "%.1f psi", 0.145038);
|
||||
|
||||
static final String PREF_KEY = "tire_pressure_unit";
|
||||
static final PressureUnit DEFAULT = BAR;
|
||||
|
||||
final String label;
|
||||
private final String pattern;
|
||||
private final double perKpa;
|
||||
|
||||
PressureUnit(String label, String pattern, double perKpa) {
|
||||
this.label = label;
|
||||
this.pattern = pattern;
|
||||
this.perKpa = perKpa;
|
||||
}
|
||||
|
||||
/** A reading in this unit, or a dash for none. */
|
||||
String format(Float kpa) {
|
||||
if (kpa == null || kpa.isNaN() || kpa.isInfinite()) return "—";
|
||||
return String.format(Locale.US, pattern, kpa * perKpa);
|
||||
}
|
||||
|
||||
/** The stored choice; anything unknown falls back to bar. */
|
||||
static PressureUnit fromPref(String name) {
|
||||
for (PressureUnit u : values()) {
|
||||
if (u.name().equals(name)) return u;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Estimated range from the energy left in the pack and how fast it is being used.
|
||||
*
|
||||
* since start: remaining kWh / (energy used / distance driven since the car was switched on)
|
||||
* average: remaining kWh / (energy used / distance driven over the last 5 minutes)
|
||||
*
|
||||
* "Since start" is a trip like the car's own trip computer: it begins with the first sample
|
||||
* after the head unit wakes (or after charging), so it includes standing with the climate on.
|
||||
* A gap of {@link #TRIP_RESET_GAP_MS} without samples is the car having been off, and starts
|
||||
* a new trip; so does charging.
|
||||
*
|
||||
* Remaining energy is SOC x usable capacity: the car publishes no pack energy, so the capacity
|
||||
* is a setting. An estimate is null when it cannot mean anything — not moved yet, too little
|
||||
* data — rather than a misleading number. Net regeneration is shown as it is: negative usage.
|
||||
*
|
||||
* Fed by both the service tick (15 s) and the open Vehicle page (2 s); the integration is
|
||||
* time-weighted, so the two rates mix correctly. Times are on the monotonic clock
|
||||
* (SystemClock.elapsedRealtime): the head unit sets its wall clock from GPS, and a jump back
|
||||
* would make every new sample look out of order. Free of Android types, unit-tested.
|
||||
*/
|
||||
final class RangeEstimator {
|
||||
|
||||
static final long WINDOW_MS = 5 * 60_000L;
|
||||
/** Less than this much driving in the window is too little to average. */
|
||||
static final long MIN_WINDOW_MS = 60_000L;
|
||||
static final double MIN_DISTANCE_KM = 0.2;
|
||||
/** A gap between samples longer than this (app paused, car off) is not integrated. */
|
||||
static final long MAX_GAP_MS = 60_000L;
|
||||
|
||||
/** No sample for this long: the car was off, and the next sample starts a new trip. */
|
||||
static final long TRIP_RESET_GAP_MS = 10 * 60_000L;
|
||||
/** Standing still and taking in more than this: charging, which ends the trip. */
|
||||
static final double CHARGING_KW = -1.0;
|
||||
|
||||
static final double DEFAULT_USABLE_KWH = 50.8; // MG4 51 kWh battery
|
||||
|
||||
private static final RangeEstimator SHARED = new RangeEstimator();
|
||||
|
||||
static RangeEstimator shared() { return SHARED; }
|
||||
|
||||
/** An estimate: the range, the consumption it is based on, and the distance behind it. */
|
||||
static final class Estimate {
|
||||
final double rangeKm;
|
||||
final double kwhPer100Km;
|
||||
final double distanceKm;
|
||||
|
||||
Estimate(double rangeKm, double kwhPer100Km, double distanceKm) {
|
||||
this.rangeKm = rangeKm;
|
||||
this.kwhPer100Km = kwhPer100Km;
|
||||
this.distanceKm = distanceKm;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Sample {
|
||||
final long timeMs;
|
||||
final double powerKw;
|
||||
final double speedKmh;
|
||||
|
||||
Sample(long timeMs, double powerKw, double speedKmh) {
|
||||
this.timeMs = timeMs;
|
||||
this.powerKw = powerKw;
|
||||
this.speedKmh = speedKmh;
|
||||
}
|
||||
}
|
||||
|
||||
private final ArrayDeque<Sample> samples = new ArrayDeque<>();
|
||||
|
||||
// The trip since start: running totals, so it is not limited to the 5-minute window.
|
||||
private double tripEnergyKwh;
|
||||
private double tripDistanceKm;
|
||||
private Sample tripLast;
|
||||
|
||||
/** Power in kW, positive leaving the battery; speed in km/h. Unread values are skipped. */
|
||||
synchronized void add(long timeMs, Float powerKw, Float speedKmh) {
|
||||
if (powerKw == null || speedKmh == null || !Float.isFinite(powerKw) || !Float.isFinite(speedKmh)) return;
|
||||
Sample last = samples.peekLast();
|
||||
if (last != null && timeMs <= last.timeMs) return; // out of order or duplicate tick
|
||||
Sample sample = new Sample(timeMs, powerKw, Math.max(0f, speedKmh));
|
||||
addToTrip(sample);
|
||||
samples.addLast(sample);
|
||||
while (!samples.isEmpty() && samples.peekFirst().timeMs < timeMs - WINDOW_MS) samples.removeFirst();
|
||||
}
|
||||
|
||||
static Double remainingKwh(Float socPercent, double usableKwh) {
|
||||
if (socPercent == null || !Float.isFinite(socPercent) || usableKwh <= 0) return null;
|
||||
return Math.max(0, Math.min(100, socPercent)) / 100.0 * usableKwh;
|
||||
}
|
||||
|
||||
private void addToTrip(Sample s) {
|
||||
boolean charging = s.speedKmh < 1 && s.powerKw < CHARGING_KW;
|
||||
if (charging || (tripLast != null && s.timeMs - tripLast.timeMs > TRIP_RESET_GAP_MS)) {
|
||||
tripEnergyKwh = 0;
|
||||
tripDistanceKm = 0;
|
||||
tripLast = null;
|
||||
}
|
||||
if (charging) return; // the trip starts again after the charge
|
||||
if (tripLast != null) {
|
||||
long dt = s.timeMs - tripLast.timeMs;
|
||||
if (dt > 0 && dt <= MAX_GAP_MS) {
|
||||
double hours = dt / 3_600_000.0;
|
||||
tripEnergyKwh += (tripLast.powerKw + s.powerKw) / 2 * hours;
|
||||
tripDistanceKm += (tripLast.speedKmh + s.speedKmh) / 2 * hours;
|
||||
}
|
||||
}
|
||||
tripLast = s;
|
||||
}
|
||||
|
||||
/** Net energy taken from the pack since start, in kWh; null before the trip's first sample. */
|
||||
synchronized Double energySinceStart() {
|
||||
return tripLast == null ? null : tripEnergyKwh;
|
||||
}
|
||||
|
||||
/** Range at the consumption since start; null until the trip has some distance behind it. */
|
||||
synchronized Estimate sinceStart(Double remainingKwh) {
|
||||
if (remainingKwh == null || tripDistanceKm < MIN_DISTANCE_KM) return null;
|
||||
return estimate(remainingKwh, tripEnergyKwh, tripDistanceKm);
|
||||
}
|
||||
|
||||
synchronized Estimate average(Double remainingKwh, long nowMs) {
|
||||
if (remainingKwh == null) return null;
|
||||
double energyKwh = 0, distanceKm = 0;
|
||||
long covered = 0;
|
||||
Sample previous = null;
|
||||
for (Iterator<Sample> it = samples.iterator(); it.hasNext(); ) {
|
||||
Sample s = it.next();
|
||||
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
||||
if (previous != null) {
|
||||
long dt = s.timeMs - previous.timeMs;
|
||||
if (dt > 0 && dt <= MAX_GAP_MS) {
|
||||
double hours = dt / 3_600_000.0;
|
||||
energyKwh += (previous.powerKw + s.powerKw) / 2 * hours;
|
||||
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * hours;
|
||||
covered += dt;
|
||||
}
|
||||
}
|
||||
previous = s;
|
||||
}
|
||||
if (covered < MIN_WINDOW_MS || distanceKm < MIN_DISTANCE_KM) return null;
|
||||
return estimate(remainingKwh, energyKwh, distanceKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Net regeneration (downhill, recuperating) is a real reading: the usage is negative, and
|
||||
* the range is unbounded rather than negative.
|
||||
*/
|
||||
private static Estimate estimate(double remainingKwh, double energyKwh, double distanceKm) {
|
||||
double kwhPerKm = energyKwh / distanceKm;
|
||||
double rangeKm = kwhPerKm > 0 ? remainingKwh / kwhPerKm : Double.POSITIVE_INFINITY;
|
||||
return new Estimate(rangeKm, kwhPerKm * 100, distanceKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean battery power over the last five minutes, time-weighted, standing included; null with
|
||||
* less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it.
|
||||
*/
|
||||
synchronized Double averagePowerKw(long nowMs) {
|
||||
double energyKwh = 0;
|
||||
long covered = 0;
|
||||
Sample previous = null;
|
||||
for (Sample s : samples) {
|
||||
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
||||
if (previous != null) {
|
||||
long dt = s.timeMs - previous.timeMs;
|
||||
if (dt > 0 && dt <= MAX_GAP_MS) {
|
||||
energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0);
|
||||
covered += dt;
|
||||
}
|
||||
}
|
||||
previous = s;
|
||||
}
|
||||
if (covered < MIN_WINDOW_MS) return null;
|
||||
return energyKwh / (covered / 3_600_000.0);
|
||||
}
|
||||
|
||||
/** Mean speed over the last five minutes, time-weighted, standing included; null below a minute. */
|
||||
synchronized Double averageSpeedKmh(long nowMs) {
|
||||
double distanceKm = 0;
|
||||
long covered = 0;
|
||||
Sample previous = null;
|
||||
for (Sample s : samples) {
|
||||
if (s.timeMs < nowMs - WINDOW_MS) continue;
|
||||
if (previous != null) {
|
||||
long dt = s.timeMs - previous.timeMs;
|
||||
if (dt > 0 && dt <= MAX_GAP_MS) {
|
||||
distanceKm += (previous.speedKmh + s.speedKmh) / 2 * (dt / 3_600_000.0);
|
||||
covered += dt;
|
||||
}
|
||||
}
|
||||
previous = s;
|
||||
}
|
||||
if (covered < MIN_WINDOW_MS) return null;
|
||||
return distanceKm / (covered / 3_600_000.0);
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
samples.clear();
|
||||
tripEnergyKwh = 0;
|
||||
tripDistanceKm = 0;
|
||||
tripLast = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||
import androidx.security.crypto.MasterKey;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Encrypted storage for the server settings — above all the ingest token, which `adb backup`
|
||||
* would otherwise hand out with the regular SharedPreferences file. Backed by an
|
||||
* AndroidKeyStore master key.
|
||||
*
|
||||
* If the keystore fails, the settings go to a plaintext file for that run rather than the app
|
||||
* crashing on the head unit. The next run that opens the encrypted store moves them back in
|
||||
* and deletes the file ({@link #mergePlaintext}), so a token saved meanwhile is neither lost
|
||||
* nor left in plaintext.
|
||||
*/
|
||||
final class SecurePrefs {
|
||||
|
||||
private static final String TAG = "SecurePrefs";
|
||||
private static final String FILE_NAME = "server_secure_prefs";
|
||||
private static final String PLAIN_NAME = "server_prefs";
|
||||
|
||||
private static SharedPreferences instance;
|
||||
|
||||
private SecurePrefs() { }
|
||||
|
||||
/**
|
||||
* Encrypted preferences, or a plaintext file if the crypto stack is unavailable. Falling
|
||||
* back keeps the app usable on a head unit with a broken keystore instead of crashing it.
|
||||
*/
|
||||
static synchronized SharedPreferences get(Context context) {
|
||||
if (instance != null) return instance;
|
||||
Context app = context.getApplicationContext();
|
||||
try {
|
||||
MasterKey masterKey = new MasterKey.Builder(app)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build();
|
||||
SharedPreferences encrypted = EncryptedSharedPreferences.create(
|
||||
app,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
|
||||
migrateFromPlaintext(app, encrypted);
|
||||
instance = encrypted;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
|
||||
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Moves what a fallback run wrote into the encrypted store, then deletes the plaintext. */
|
||||
private static void migrateFromPlaintext(Context app, SharedPreferences encrypted) {
|
||||
SharedPreferences plain = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
||||
Map<String, ?> leftover = plain.getAll();
|
||||
if (leftover.isEmpty()) return;
|
||||
Map<String, String> moves = mergePlaintext(strings(leftover), strings(encrypted.getAll()));
|
||||
SharedPreferences.Editor edit = encrypted.edit();
|
||||
for (Map.Entry<String, String> e : moves.entrySet()) edit.putString(e.getKey(), e.getValue());
|
||||
if (edit.commit()) {
|
||||
plain.edit().clear().commit();
|
||||
Log.i(TAG, "Moved " + moves.size() + " setting(s) from plaintext into encrypted storage");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to write into the encrypted store from a fallback run's plaintext file. The plaintext
|
||||
* is newer, so its URL and token win — that is a pairing done while the keystore was down.
|
||||
* The installation id is the exception: the encrypted one is what the car's history is
|
||||
* stored under, and a fallback run may only have made up a new one.
|
||||
*/
|
||||
static Map<String, String> mergePlaintext(Map<String, String> plain, Map<String, String> encrypted) {
|
||||
Map<String, String> moves = new HashMap<>();
|
||||
for (Map.Entry<String, String> e : plain.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
String existing = encrypted.get(e.getKey());
|
||||
if (ServerConfig.KEY_INSTALLATION_ID.equals(e.getKey()) && existing != null && !existing.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
moves.put(e.getKey(), e.getValue());
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
private static Map<String, String> strings(Map<String, ?> all) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
for (Map.Entry<String, ?> e : all.entrySet()) {
|
||||
if (e.getValue() instanceof String) out.put(e.getKey(), (String) e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Where the car's data goes: the server URL, its ingest token, and the installation id the
|
||||
* data is stored under. Entered in the Server tab or imported from a config file, kept in
|
||||
* {@link SecurePrefs}.
|
||||
*
|
||||
* Read through {@link #get}, which caches one immutable snapshot for the process and is
|
||||
* replaced on {@link #save}: the service and the activity both see a change at once, without
|
||||
* a restart.
|
||||
*/
|
||||
final class ServerConfig {
|
||||
|
||||
static final String KEY_URL = "server_url";
|
||||
static final String KEY_TOKEN = "ingest_token";
|
||||
static final String KEY_INSTALLATION_ID = "installation_id";
|
||||
|
||||
/** Same rule as the server: a version 1–5 UUID with the RFC 4122 variant. */
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||
"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** Pre-filled so pairing needs no typing in the car; can still be changed. */
|
||||
static final String DEFAULT_URL = "https://car.k8s.stinnesbeck.com";
|
||||
|
||||
/** The server refuses shorter ingest tokens outright. */
|
||||
static final int MIN_TOKEN_LENGTH = 24;
|
||||
|
||||
final String url;
|
||||
final String token;
|
||||
final String installationId;
|
||||
|
||||
private static volatile ServerConfig current;
|
||||
|
||||
ServerConfig(String url, String token, String installationId) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.installationId = installationId;
|
||||
}
|
||||
|
||||
boolean isConfigured() {
|
||||
return !url.isEmpty() && !token.isEmpty();
|
||||
}
|
||||
|
||||
// ANDROID_ID on purpose: it is hashed into the installation id, so a reinstall keeps its
|
||||
// history (installationIdFor). It is never sent or logged as it is.
|
||||
@android.annotation.SuppressLint("HardwareIds")
|
||||
static ServerConfig get(Context context) {
|
||||
ServerConfig config = current;
|
||||
if (config != null) return config;
|
||||
synchronized (ServerConfig.class) {
|
||||
if (current != null) return current;
|
||||
SharedPreferences prefs = SecurePrefs.get(context);
|
||||
String id = prefs.getString(KEY_INSTALLATION_ID, "");
|
||||
if (!isValidInstallationId(id)) {
|
||||
// First start, or the first after a reinstall: derived from the head unit, so
|
||||
// the history stays under the same id (see installationIdFor).
|
||||
String androidId = null;
|
||||
try {
|
||||
androidId = android.provider.Settings.Secure.getString(
|
||||
context.getApplicationContext().getContentResolver(),
|
||||
android.provider.Settings.Secure.ANDROID_ID);
|
||||
} catch (RuntimeException ignored) {
|
||||
// no id from the platform: a random one below
|
||||
}
|
||||
id = installationIdFor(androidId);
|
||||
if (id == null) id = UUID.randomUUID().toString();
|
||||
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
|
||||
}
|
||||
current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores already-validated values; see {@link #validate}. */
|
||||
static synchronized void save(Context context, String url, String token, String installationId) {
|
||||
SecurePrefs.get(context).edit()
|
||||
.putString(KEY_URL, url)
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_INSTALLATION_ID, installationId)
|
||||
.apply();
|
||||
current = new ServerConfig(url, token, installationId);
|
||||
}
|
||||
|
||||
// ---------- Validation (pure, unit-tested) ----------
|
||||
|
||||
/**
|
||||
* The URL as it will be used: trimmed, without a trailing slash. Null when it is not an
|
||||
* https URL with a host — the token travels in a header, so plain http is refused.
|
||||
*/
|
||||
static String normalizeUrl(String raw) {
|
||||
if (raw == null) return null;
|
||||
String url = raw.trim();
|
||||
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
|
||||
if (!url.toLowerCase(Locale.US).startsWith("https://")) return null;
|
||||
String rest = url.substring("https://".length());
|
||||
if (rest.isEmpty() || rest.startsWith("/") || rest.contains(" ") || rest.contains("?")
|
||||
|| rest.contains("#") || rest.contains("@")) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
static boolean isValidToken(String token) {
|
||||
return token != null && token.trim().length() >= MIN_TOKEN_LENGTH && !token.trim().contains(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The installation id for a head unit, or null without a usable ANDROID_ID. From Android 8
|
||||
* ANDROID_ID is fixed per device and signing key and survives an uninstall, so a
|
||||
* reinstall of the same platform-signed build finds its history again without importing
|
||||
* the id. Hashed into a name-based UUID: the server expects a UUID, and the raw
|
||||
* ANDROID_ID never leaves the car. An id already stored (or imported) always wins over it.
|
||||
*/
|
||||
static String installationIdFor(String androidId) {
|
||||
if (androidId == null) return null;
|
||||
String trimmed = androidId.trim();
|
||||
// The emulator-era constant that many devices once shared; not an identity.
|
||||
if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("9774d56d682e549c")) return null;
|
||||
byte[] name = ("nilsmg4:" + trimmed.toLowerCase(Locale.US)).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
return UUID.nameUUIDFromBytes(name).toString();
|
||||
}
|
||||
|
||||
static boolean isValidInstallationId(String id) {
|
||||
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* One JSON request to the car server, shared by telemetry, consumption sync and pairing (and,
|
||||
* through {@link #send}, the direct ABRP upload).
|
||||
* Blocking, with short timeouts — call it off the main thread.
|
||||
*
|
||||
* The token goes in the Authorization header, never the URL, and is never logged. The answer
|
||||
* body is read for every status (error bodies name the problem) and capped, so a wrong server
|
||||
* cannot fill memory.
|
||||
*/
|
||||
final class ServerHttp {
|
||||
|
||||
static final int TIMEOUT_MS = 8_000;
|
||||
private static final int MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
/** Status and body of an answer. */
|
||||
static final class Response {
|
||||
final int status;
|
||||
final String body;
|
||||
|
||||
Response(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
boolean ok() { return status >= 200 && status < 300; }
|
||||
|
||||
/** The body for a log line: short, on one line. */
|
||||
String excerpt() {
|
||||
String text = body.replace('\n', ' ').trim();
|
||||
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
|
||||
}
|
||||
}
|
||||
|
||||
private ServerHttp() { }
|
||||
|
||||
/**
|
||||
* Sends {@code json} (or nothing, when null) with {@code method} to {@code url}.
|
||||
* {@code token} may be null for the unauthenticated pairing calls.
|
||||
*/
|
||||
static Response request(String method, String url, String token, JSONObject json) throws Exception {
|
||||
byte[] body = json == null ? null : json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return send(method, url, token == null ? null : "Bearer " + token, "application/json", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The request underneath, also used for ABRP ({@link AbrpApi}): {@code authorization} is the
|
||||
* whole header value or null, {@code body} null for none.
|
||||
*/
|
||||
static Response send(String method, String url, String authorization, String contentType, byte[] body)
|
||||
throws Exception {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
if (authorization != null) connection.setRequestProperty("Authorization", authorization);
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setFixedLengthStreamingMode(body.length);
|
||||
connection.setRequestProperty("Content-Type", contentType);
|
||||
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
|
||||
}
|
||||
int status = connection.getResponseCode();
|
||||
return new Response(status, readBody(connection, status));
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection connection, int status) {
|
||||
try {
|
||||
InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
if (in == null) return "";
|
||||
try (InputStream stream = in) {
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int n;
|
||||
while ((n = stream.read(chunk)) != -1 && buf.size() < MAX_BODY_BYTES) buf.write(chunk, 0, n);
|
||||
return buf.toString(StandardCharsets.UTF_8.name());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// The status is what matters; an unreadable body is just empty.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.ScrollView;
|
||||
|
||||
/**
|
||||
* A ScrollView that keeps the position the driver scrolled to.
|
||||
*
|
||||
* The pages live in ViewPager2's RecyclerView. When the live values change, the tiles ask for
|
||||
* a new layout, and the RecyclerView lays the pages out once already while it is still
|
||||
* measuring, with a provisional height at which the content seems to fit. ScrollView.onLayout
|
||||
* then clamps the scroll position to 0, and the real layout that follows keeps it there: every
|
||||
* refresh threw the page back to the top (seen in a stack trace on the emulator).
|
||||
*
|
||||
* Here only scrolling that is not part of a layout counts as the driver's position, and every
|
||||
* layout restores it. ScrollView.scrollTo clamps against the real content height, so a
|
||||
* position that no longer exists still ends at the bottom rather than past it.
|
||||
*
|
||||
* No scroll bar: a ScrollView shows its bar on every layout, and the live values lay the page
|
||||
* out every two seconds, so on the car it never faded. The pages scroll by touch.
|
||||
*/
|
||||
public class StableScrollView extends ScrollView {
|
||||
|
||||
private int wantedScrollY;
|
||||
private boolean inLayout;
|
||||
|
||||
public StableScrollView(Context context) {
|
||||
super(context);
|
||||
setVerticalScrollBarEnabled(false);
|
||||
}
|
||||
|
||||
public StableScrollView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
setVerticalScrollBarEnabled(false);
|
||||
}
|
||||
|
||||
public StableScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
setVerticalScrollBarEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
|
||||
super.onScrollChanged(l, t, oldl, oldt);
|
||||
if (!inLayout) wantedScrollY = t;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||
inLayout = true;
|
||||
try {
|
||||
super.onLayout(changed, l, t, r, b);
|
||||
if (getScrollY() != wantedScrollY) scrollTo(getScrollX(), wantedScrollY);
|
||||
} finally {
|
||||
inLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
/**
|
||||
* Opens the app on a double press of the steering wheel's voice key.
|
||||
*
|
||||
* The head unit reports steering-wheel keys as the broadcast {@link #ACTION}; code 287 is the
|
||||
* voice key (see EVHardware's PhysicalButtonEventDecoder). The extras' format was read off the
|
||||
* car (see {@link SteeringKeyReport}).
|
||||
*
|
||||
* Registered by the always-on service on the main thread; never throws into the head unit.
|
||||
*/
|
||||
final class SteeringKeyListener {
|
||||
|
||||
static final String ACTION = "com.saic.keyevent.hardkey.report";
|
||||
static final int VOICE_KEY = 287;
|
||||
private static final String TAG = "NilsMG4.SteeringKey";
|
||||
|
||||
private final Context context;
|
||||
private final SharedPreferences prefs;
|
||||
private final DoublePressDetector detector = new DoublePressDetector(VOICE_KEY);
|
||||
private boolean registered;
|
||||
|
||||
private final BroadcastReceiver receiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context c, Intent intent) {
|
||||
try {
|
||||
handle(intent);
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "Key broadcast not handled: " + e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SteeringKeyListener(Context context, SharedPreferences prefs) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.prefs = prefs;
|
||||
}
|
||||
|
||||
void register() {
|
||||
try {
|
||||
// Exported on purpose: the report comes from the head unit's own key service, another
|
||||
// app. The flag is required from API 33 and absent on this API-28 head unit;
|
||||
// ContextCompat picks the right call for the platform underneath.
|
||||
androidx.core.content.ContextCompat.registerReceiver(context, receiver, new IntentFilter(ACTION),
|
||||
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
|
||||
registered = true;
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "Key broadcasts not available: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (!registered) return;
|
||||
try {
|
||||
context.unregisterReceiver(receiver);
|
||||
} catch (RuntimeException ignored) {
|
||||
// already gone
|
||||
}
|
||||
registered = false;
|
||||
}
|
||||
|
||||
private void handle(Intent intent) {
|
||||
Bundle extras = intent.getExtras();
|
||||
SteeringKeyReport key = read(extras);
|
||||
boolean opens = false;
|
||||
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
|
||||
opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime());
|
||||
}
|
||||
if (opens) {
|
||||
try {
|
||||
context.startActivity(new Intent(context, MainActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "App not opened: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reading the broadcast ----------
|
||||
|
||||
/** An embedded KeyEvent if there is one, otherwise the extras by name (SteeringKeyReport). */
|
||||
static SteeringKeyReport read(Bundle extras) {
|
||||
if (extras == null) return SteeringKeyReport.parse(null);
|
||||
java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
for (String k : extras.keySet()) {
|
||||
Object v = extras.get(k);
|
||||
if (v instanceof KeyEvent) {
|
||||
KeyEvent e = (KeyEvent) v;
|
||||
return new SteeringKeyReport(e.getKeyCode(), e.getAction() == KeyEvent.ACTION_DOWN, e.isLongPress());
|
||||
}
|
||||
values.put(k, v);
|
||||
}
|
||||
return SteeringKeyReport.parse(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Reads one steering-wheel key report from the broadcast's extras.
|
||||
*
|
||||
* Seen on the MG4 (2026-10-17, on-screen key log): the head unit sends
|
||||
* android.intent.extra.hardkey.keycode = 287 (Integer)
|
||||
* android.intent.extra.hardkey.down = true (Boolean, then false on release)
|
||||
* android.intent.extra.hardkey.longpress = false (Boolean)
|
||||
* Names are matched on their last dot-separated part, case-insensitively, so this exact form
|
||||
* and simpler ones ("keyCode", "down", …) are both understood.
|
||||
*
|
||||
* Android-free (the extras arrive as a plain map), unit-tested.
|
||||
*/
|
||||
final class SteeringKeyReport {
|
||||
|
||||
final Integer code;
|
||||
final Boolean down;
|
||||
final boolean longPress;
|
||||
|
||||
SteeringKeyReport(Integer code, Boolean down, boolean longPress) {
|
||||
this.code = code;
|
||||
this.down = down;
|
||||
this.longPress = longPress;
|
||||
}
|
||||
|
||||
private static final String[] CODE_NAMES = {"keycode", "key_code", "keyvalue", "key_value", "code", "key"};
|
||||
private static final String[] ACTION_NAMES = {"keyaction", "key_action", "action", "keystate", "key_state",
|
||||
"state", "event", "type"};
|
||||
private static final String[] DOWN_NAMES = {"down", "isdown", "pressed", "ispressed"};
|
||||
private static final String[] LONG_NAMES = {"longpress", "islongpress", "long_press", "islong", "long"};
|
||||
|
||||
static SteeringKeyReport parse(Map<String, Object> extras) {
|
||||
Map<String, Object> byName = new HashMap<>();
|
||||
if (extras != null) {
|
||||
for (Map.Entry<String, Object> e : extras.entrySet()) {
|
||||
String key = e.getKey();
|
||||
String last = key.substring(key.lastIndexOf('.') + 1).toLowerCase(Locale.US);
|
||||
byName.putIfAbsent(last, e.getValue());
|
||||
}
|
||||
}
|
||||
Integer code = firstInt(byName, CODE_NAMES);
|
||||
Boolean down = null;
|
||||
boolean longPress = false;
|
||||
// KeyEvent's own convention for a numeric action: 0 down, 1 up; anything else is long.
|
||||
Integer action = firstInt(byName, ACTION_NAMES);
|
||||
if (action != null) {
|
||||
if (action == 0) down = true;
|
||||
else if (action == 1) down = false;
|
||||
else longPress = true;
|
||||
}
|
||||
Boolean explicitDown = firstBoolean(byName, DOWN_NAMES);
|
||||
if (explicitDown != null) down = explicitDown;
|
||||
Boolean explicitLong = firstBoolean(byName, LONG_NAMES);
|
||||
if (explicitLong != null) longPress = explicitLong;
|
||||
return new SteeringKeyReport(code, down, longPress);
|
||||
}
|
||||
|
||||
private static Integer firstInt(Map<String, Object> byName, String[] names) {
|
||||
for (String n : names) {
|
||||
Object v = byName.get(n);
|
||||
if (v instanceof Integer) return (Integer) v;
|
||||
if (v instanceof Long) return (int) (long) (Long) v;
|
||||
if (v instanceof String) {
|
||||
try { return Integer.parseInt(((String) v).trim()); } catch (NumberFormatException ignored) { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean firstBoolean(Map<String, Object> byName, String[] names) {
|
||||
for (String n : names) {
|
||||
Object v = byName.get(n);
|
||||
if (v instanceof Boolean) return (Boolean) v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Builds one live telemetry sample in ABRP's tlm format. The car server stores it and forwards
|
||||
* the newest one to ABRP, so the field names and units are exactly ABRP's.
|
||||
*
|
||||
* Kept free of Android types so it can be unit-tested on the JVM: the caller
|
||||
* pulls the values out of the car adapter and the Location, this class only
|
||||
* decides what ends up in the payload.
|
||||
*
|
||||
* Every vehicle field is nullable and a null field is OMITTED. A property that
|
||||
* cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP
|
||||
* the battery is empty and wrecks the user's route plan.
|
||||
*
|
||||
* Fields are set by name rather than passed positionally. The payload now carries
|
||||
* 24 optional values and a positional build(...) call had become a wall of nulls
|
||||
* that no reader could check against the ABRP field list.
|
||||
*/
|
||||
final class TelemetryPayload {
|
||||
|
||||
/** Only value that is always present. Epoch SECONDS, not milliseconds. */
|
||||
private final long utc;
|
||||
|
||||
// --- High priority (ABRP: "many features will only be usable with enough data") ---
|
||||
Integer soc; // %
|
||||
Float speedKmh; // km/h
|
||||
Float powerKw; // kW, ABRP sign: positive leaving the battery
|
||||
Boolean charging;
|
||||
Boolean dcfc;
|
||||
Boolean parked;
|
||||
Double lat; // °
|
||||
Double lon; // °
|
||||
|
||||
// --- Lower priority ---
|
||||
Integer rangeKm; // km, vehicle's own estimate
|
||||
Float cabinTemp; // °C
|
||||
Float battTempC; // °C, traction battery
|
||||
Float capacityKwh; // kWh, usable pack capacity
|
||||
Float soeKwh; // kWh, energy currently in the pack
|
||||
Double kwhCharged; // kWh, taken by the pack during the current charge session
|
||||
Float extTempC; // °C, outside
|
||||
Float voltageV; // V, traction battery
|
||||
Float currentA; // A, traction battery
|
||||
Float odometerKm; // km
|
||||
Float hvacSetpointC; // °C, climate setpoint
|
||||
Float tirePressureFlKpa;
|
||||
Float tirePressureFrKpa;
|
||||
Float tirePressureRlKpa;
|
||||
Float tirePressureRrKpa;
|
||||
Double elevation; // m
|
||||
Float heading; // °
|
||||
|
||||
TelemetryPayload(long utc) {
|
||||
this.utc = utc;
|
||||
}
|
||||
|
||||
/** Build the {@code tlm} JSON object. Only utc is always present. */
|
||||
String build() {
|
||||
JSONObject tlm = new JSONObject();
|
||||
try {
|
||||
tlm.put("utc", utc);
|
||||
|
||||
putIfPresent(tlm, "soc", soc);
|
||||
// Reversing reads negative; ABRP and the server take speed as a magnitude.
|
||||
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(Math.abs(speedKmh)));
|
||||
putIfPresent(tlm, "est_battery_range", rangeKm);
|
||||
if (powerKw != null) tlm.put("power", round2(powerKw));
|
||||
putIfPresent(tlm, "is_charging", boolToInt(charging));
|
||||
putIfPresent(tlm, "is_dcfc", boolToInt(dcfc));
|
||||
putIfPresent(tlm, "is_parked", boolToInt(parked));
|
||||
|
||||
// Only send cabin_temp if we got a plausible reading — 0.0 likely
|
||||
// means the property isn't supported on this VHAL.
|
||||
if (isPlausibleTemp(cabinTemp)) {
|
||||
tlm.put("cabin_temp", Math.round(cabinTemp));
|
||||
}
|
||||
// Same guard for the pack: a VHAL that does not implement the property
|
||||
// answers 0.0, and "battery at 0 °C" is a reading ABRP acts on.
|
||||
if (isPlausibleTemp(battTempC)) {
|
||||
tlm.put("batt_temp", Math.round(battTempC));
|
||||
}
|
||||
// hvac_setpoint is a user setting, so 0 °C is not a real value either —
|
||||
// no cabin climate control is ever set to freezing.
|
||||
if (isPlausibleTemp(hvacSetpointC)) {
|
||||
tlm.put("hvac_setpoint", Math.round(hvacSetpointC));
|
||||
}
|
||||
|
||||
// A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time
|
||||
// it runs this app: both zeros mean "property not implemented".
|
||||
if (capacityKwh != null && capacityKwh > 0f) {
|
||||
tlm.put("capacity", round2(capacityKwh));
|
||||
}
|
||||
if (odometerKm != null && odometerKm > 0f) {
|
||||
tlm.put("odometer", Math.round(odometerKm));
|
||||
}
|
||||
// Same reasoning as capacity: a car that is running this app has some energy
|
||||
// in its pack, so 0 kWh is the VHAL saying it does not implement the property.
|
||||
if (soeKwh != null && soeKwh > 0f) {
|
||||
tlm.put("soe", round2(soeKwh));
|
||||
}
|
||||
// 0.0 IS meaningful here — it is a charge session that has just begun — so
|
||||
// This one is guarded on null only. EVHardware's charging-session meter sends
|
||||
// null between sessions.
|
||||
if (kwhCharged != null) {
|
||||
tlm.put("kwh_charged", kwhCharged.doubleValue());
|
||||
}
|
||||
if (isPlausibleTemp(extTempC)) {
|
||||
tlm.put("ext_temp", Math.round(extTempC));
|
||||
}
|
||||
// A pack under load is never at 0 V; 0 is the property not being implemented.
|
||||
if (voltageV != null && voltageV > 0f && voltageV < 1500f) {
|
||||
tlm.put("voltage", round2(voltageV));
|
||||
}
|
||||
// 0 A is a real reading (idle), so this one is guarded on range only.
|
||||
if (currentA != null && currentA > -2000f && currentA < 2000f) {
|
||||
tlm.put("current", round2(currentA));
|
||||
}
|
||||
|
||||
putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa));
|
||||
putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa));
|
||||
putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa));
|
||||
putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa));
|
||||
|
||||
if (lat != null && lon != null) {
|
||||
tlm.put("lat", lat.doubleValue());
|
||||
tlm.put("lon", lon.doubleValue());
|
||||
putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation));
|
||||
putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
// JSONObject.put only throws on NaN/Infinity values, which the guards above
|
||||
// already exclude. Returning just the timestamp keeps the upload alive.
|
||||
return "{\"utc\":" + utc + "}";
|
||||
}
|
||||
dropOutOfRange(tlm);
|
||||
return tlm.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's range for every numeric field (server/model.go, telemetryNumbers). A value
|
||||
* outside it is left out here, as an unread one would be: the server would drop it anyway,
|
||||
* and an older server rejects the whole batch for it.
|
||||
*/
|
||||
static final java.util.Map<String, double[]> SERVER_LIMITS = new java.util.HashMap<>();
|
||||
static {
|
||||
SERVER_LIMITS.put("soc", new double[]{0, 100});
|
||||
SERVER_LIMITS.put("speed", new double[]{0, 400});
|
||||
SERVER_LIMITS.put("power", new double[]{-1000, 1000});
|
||||
SERVER_LIMITS.put("est_battery_range", new double[]{0, 2000});
|
||||
SERVER_LIMITS.put("capacity", new double[]{0, 500});
|
||||
SERVER_LIMITS.put("soe", new double[]{0, 500});
|
||||
SERVER_LIMITS.put("kwh_charged", new double[]{0, 1000});
|
||||
SERVER_LIMITS.put("odometer", new double[]{0, 10_000_000});
|
||||
SERVER_LIMITS.put("lat", new double[]{-90, 90});
|
||||
SERVER_LIMITS.put("lon", new double[]{-180, 180});
|
||||
SERVER_LIMITS.put("elevation", new double[]{-1000, 10_000});
|
||||
SERVER_LIMITS.put("heading", new double[]{0, 360});
|
||||
SERVER_LIMITS.put("batt_temp", new double[]{-60, 100});
|
||||
SERVER_LIMITS.put("cabin_temp", new double[]{-60, 100});
|
||||
SERVER_LIMITS.put("ext_temp", new double[]{-60, 70});
|
||||
SERVER_LIMITS.put("hvac_setpoint", new double[]{0, 40});
|
||||
SERVER_LIMITS.put("voltage", new double[]{0, 1500});
|
||||
SERVER_LIMITS.put("current", new double[]{-2000, 2000});
|
||||
SERVER_LIMITS.put("tire_pressure_fl", new double[]{0, 1000});
|
||||
SERVER_LIMITS.put("tire_pressure_fr", new double[]{0, 1000});
|
||||
SERVER_LIMITS.put("tire_pressure_rl", new double[]{0, 1000});
|
||||
SERVER_LIMITS.put("tire_pressure_rr", new double[]{0, 1000});
|
||||
}
|
||||
|
||||
private static void dropOutOfRange(JSONObject tlm) {
|
||||
for (java.util.Map.Entry<String, double[]> limit : SERVER_LIMITS.entrySet()) {
|
||||
String key = limit.getKey();
|
||||
if (!tlm.has(key)) continue;
|
||||
double value = tlm.optDouble(key, Double.NaN);
|
||||
if (Double.isNaN(value) || value < limit.getValue()[0] || value > limit.getValue()[1]) tlm.remove(key);
|
||||
}
|
||||
// A position is only a position as a pair.
|
||||
if (tlm.has("lat") != tlm.has("lon")) {
|
||||
tlm.remove("lat");
|
||||
tlm.remove("lon");
|
||||
}
|
||||
}
|
||||
|
||||
/** Epoch seconds the server accepts: from 2001. Earlier means the head unit's clock is not set yet. */
|
||||
static final long MIN_UTC = 1_000_000_000L;
|
||||
|
||||
static boolean isPlausibleUtc(long utcSeconds) {
|
||||
return utcSeconds >= MIN_UTC;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a temperature that a sensor could actually have measured. Excludes exactly
|
||||
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
|
||||
*
|
||||
* Package-private because the service asks the same question before deciding whether to
|
||||
* look for an outside temperature somewhere other than the car.
|
||||
*/
|
||||
static boolean isPlausibleTemp(Float celsius) {
|
||||
return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded kPa, or null when the reading is outside anything a road tyre reports.
|
||||
* A flat tyre still reads well above zero, so 0 means the wheel has no sensor.
|
||||
*/
|
||||
private static Integer kpa(Float pressure) {
|
||||
if (pressure == null || pressure < 50f || pressure > 500f) return null;
|
||||
return Math.round(pressure);
|
||||
}
|
||||
|
||||
private static void putIfPresent(JSONObject json, String key, Object value)
|
||||
throws JSONException {
|
||||
if (value != null) json.put(key, value);
|
||||
}
|
||||
|
||||
private static Integer boolToInt(Boolean value) {
|
||||
return value == null ? null : (value ? 1 : 0);
|
||||
}
|
||||
|
||||
/** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */
|
||||
private static double round2(float value) {
|
||||
return Math.round(value * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field {@link #build()} can emit, in the order the summary lists them.
|
||||
* Used only to work out what was left out — see {@link #summarize(String)}.
|
||||
*/
|
||||
private static final String[] ALL_FIELDS = {
|
||||
"utc", "soc", "speed", "power", "est_battery_range", "cabin_temp",
|
||||
"batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer",
|
||||
"is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading",
|
||||
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr",
|
||||
"ext_temp", "voltage", "current",
|
||||
};
|
||||
|
||||
/**
|
||||
* One-line, human-readable digest of an upload: what was sent, and what was left out.
|
||||
*
|
||||
* Derived from the JSON that actually goes on the wire rather than from the fields
|
||||
* above, so it cannot drift away from the payload it claims to describe — which is
|
||||
* the whole point of having it when a value on the ABRP side looks wrong.
|
||||
*/
|
||||
static String summarize(String tlmJson) {
|
||||
JSONObject tlm;
|
||||
try {
|
||||
tlm = new JSONObject(tlmJson);
|
||||
} catch (JSONException e) {
|
||||
return "unparseable payload: " + tlmJson;
|
||||
}
|
||||
StringBuilder sent = new StringBuilder();
|
||||
StringBuilder omitted = new StringBuilder();
|
||||
for (String field : ALL_FIELDS) {
|
||||
if (tlm.has(field)) {
|
||||
if (sent.length() > 0) sent.append(' ');
|
||||
sent.append(field).append('=').append(tlm.opt(field));
|
||||
} else {
|
||||
if (omitted.length() > 0) omitted.append(',');
|
||||
omitted.append(field);
|
||||
}
|
||||
}
|
||||
return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Live samples waiting to reach the server, oldest first.
|
||||
*
|
||||
* Bounded: while the car is offline the oldest samples are dropped once {@link #capacity}
|
||||
* is reached, so a long dead spot costs the start of the gap, never the head unit's memory.
|
||||
* Kept in memory and saved to a file now and then (TelemetryUploader), so a reboot or a
|
||||
* killed process on the way through a dead spot does not lose the drive. {@link #encode} and
|
||||
* {@link #decode} are the file format: a JSON array of the samples as they are sent.
|
||||
*
|
||||
* Free of Android types so the policy is unit-tested on the JVM.
|
||||
*/
|
||||
final class TelemetryQueue {
|
||||
|
||||
/** About eight hours at one sample per 15-second tick. */
|
||||
static final int DEFAULT_CAPACITY = 2000;
|
||||
/** The server takes at most 500 samples per request. */
|
||||
static final int MAX_BATCH = 500;
|
||||
|
||||
private final int capacity;
|
||||
private final ArrayDeque<String> samples = new ArrayDeque<>();
|
||||
private int dropped;
|
||||
|
||||
TelemetryQueue() { this(DEFAULT_CAPACITY); }
|
||||
|
||||
TelemetryQueue(int capacity) { this.capacity = capacity; }
|
||||
|
||||
/** Counts every change, so the owner can tell whether there is anything new to save. */
|
||||
private long version;
|
||||
|
||||
synchronized void add(String sampleJson) {
|
||||
version++;
|
||||
if (samples.size() >= capacity) {
|
||||
samples.removeFirst();
|
||||
dropped++;
|
||||
}
|
||||
samples.addLast(sampleJson);
|
||||
}
|
||||
|
||||
/** Up to {@link #MAX_BATCH} of the oldest samples, still queued until {@link #remove}. */
|
||||
synchronized List<String> peekBatch() {
|
||||
List<String> batch = new ArrayList<>(Math.min(samples.size(), MAX_BATCH));
|
||||
for (String sample : samples) {
|
||||
if (batch.size() >= MAX_BATCH) break;
|
||||
batch.add(sample);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** Drops the oldest {@code count} samples once the server has confirmed them. */
|
||||
synchronized void remove(int count) {
|
||||
if (count > 0) version++;
|
||||
for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst();
|
||||
}
|
||||
|
||||
/** The newest sample, or null when the queue is empty; it stays queued. */
|
||||
synchronized String newest() { return samples.peekLast(); }
|
||||
|
||||
synchronized int size() { return samples.size(); }
|
||||
|
||||
synchronized long version() { return version; }
|
||||
|
||||
/** Every queued sample, oldest first, as {@link #encode} writes them. */
|
||||
synchronized String encode() {
|
||||
org.json.JSONArray array = new org.json.JSONArray();
|
||||
for (String sample : samples) array.put(sample);
|
||||
return array.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts saved samples back in front of anything queued since, oldest first and within the
|
||||
* capacity. Unreadable input restores nothing: a damaged file costs the backlog, never
|
||||
* the app.
|
||||
*/
|
||||
synchronized int restore(String saved) {
|
||||
List<String> restored = decode(saved);
|
||||
if (restored.isEmpty()) return 0;
|
||||
ArrayDeque<String> merged = new ArrayDeque<>(restored);
|
||||
merged.addAll(samples);
|
||||
while (merged.size() > capacity) {
|
||||
merged.removeFirst();
|
||||
dropped++;
|
||||
}
|
||||
samples.clear();
|
||||
samples.addAll(merged);
|
||||
version++;
|
||||
return restored.size();
|
||||
}
|
||||
|
||||
static List<String> decode(String saved) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (saved == null || saved.isEmpty()) return out;
|
||||
try {
|
||||
org.json.JSONArray array = new org.json.JSONArray(saved);
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
String sample = array.optString(i, null);
|
||||
if (sample != null && sample.startsWith("{")) out.add(sample);
|
||||
}
|
||||
} catch (org.json.JSONException e) {
|
||||
out.clear();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Samples lost to the capacity limit since the queue was created. */
|
||||
synchronized int dropped() { return dropped; }
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.location.Location;
|
||||
import android.location.LocationListener;
|
||||
import android.location.LocationManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Live telemetry to the car server, which stores it, and the newest sample straight to ABRP.
|
||||
*
|
||||
* Called on every service tick. While the car is in use every tick becomes a sample; the
|
||||
* queue is sent when {@link UploadCadence} says so — every tick on a state change, otherwise
|
||||
* at the configured interval. Parked and not charging, a sample is only taken when one is
|
||||
* sent, every 15 minutes: nothing changes, and a sample every 15 s overnight is waste. The GPS
|
||||
* subscription follows the same rule: it is paused while parked and idle, and those samples
|
||||
* carry the fix the car parked with. {@link #stop} ends it when live telemetry is off.
|
||||
*
|
||||
* Runs on the service's scheduler thread; the HTTP calls block it, with short timeouts.
|
||||
* Location updates arrive on the main looper and are only read here.
|
||||
*/
|
||||
final class TelemetryUploader {
|
||||
|
||||
private static final String TAG = "NilsMG4.Telemetry";
|
||||
/** Batches per tick: a backlog drains over a few ticks instead of stalling one. */
|
||||
private static final int MAX_BATCHES_PER_TICK = 2;
|
||||
/** A fix older than this no longer says where the car is; position is omitted. */
|
||||
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60_000L;
|
||||
private static final long LOCATION_RETRY_MS = 30_000L;
|
||||
|
||||
private final Context context;
|
||||
private final TelemetryQueue queue = new TelemetryQueue();
|
||||
private final ChargingSignal chargingSignal = new ChargingSignal();
|
||||
|
||||
private volatile Location lastLocation;
|
||||
/** The fix kept while parked and idle, with the GPS paused; null otherwise. */
|
||||
private volatile Location parkedLocation;
|
||||
/** Whether the GPS should run; a request still queued on the main looper checks it. */
|
||||
private volatile boolean locationWanted;
|
||||
private volatile boolean locationActive;
|
||||
private volatile long lastLocationRequestMs;
|
||||
private LocationManager locationManager;
|
||||
|
||||
private long lastUploadAttemptMs;
|
||||
private Boolean lastParked;
|
||||
private Boolean lastCharging;
|
||||
|
||||
TelemetryUploader(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
load();
|
||||
}
|
||||
|
||||
// ---------- Keeping the queue across restarts ----------
|
||||
|
||||
private static final String QUEUE_FILE = "telemetry_queue.json";
|
||||
/**
|
||||
* At most this often, and only after a change: the flash is not written every 15 s, and a
|
||||
* killed process loses at most this much. A clean stop saves at once ({@link #persist}).
|
||||
*/
|
||||
private static final long SAVE_INTERVAL_MS = 5 * 60_000L;
|
||||
|
||||
private long savedVersion;
|
||||
private long lastSaveMs;
|
||||
|
||||
private android.util.AtomicFile queueFile() {
|
||||
return new android.util.AtomicFile(new java.io.File(context.getFilesDir(), QUEUE_FILE));
|
||||
}
|
||||
|
||||
private synchronized void load() {
|
||||
try {
|
||||
byte[] bytes = queueFile().readFully();
|
||||
int restored = queue.restore(new String(bytes, java.nio.charset.StandardCharsets.UTF_8));
|
||||
if (restored > 0) Log.i(TAG, restored + " queued sample(s) restored");
|
||||
} catch (java.io.FileNotFoundException e) {
|
||||
// nothing saved yet
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Saved queue not read: " + e.getClass().getSimpleName());
|
||||
}
|
||||
savedVersion = queue.version();
|
||||
lastSaveMs = SystemClock.elapsedRealtime();
|
||||
}
|
||||
|
||||
private void maybeSave() {
|
||||
if (SystemClock.elapsedRealtime() - lastSaveMs >= SAVE_INTERVAL_MS) persist();
|
||||
}
|
||||
|
||||
/** Saves the queue if it changed since the last save. Atomic: a crash keeps the old file. */
|
||||
synchronized void persist() {
|
||||
long version = queue.version();
|
||||
lastSaveMs = SystemClock.elapsedRealtime();
|
||||
if (version == savedVersion) return;
|
||||
android.util.AtomicFile file = queueFile();
|
||||
java.io.FileOutputStream out = null;
|
||||
try {
|
||||
out = file.startWrite();
|
||||
out.write(queue.encode().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
file.finishWrite(out);
|
||||
savedVersion = version;
|
||||
} catch (Exception e) {
|
||||
if (out != null) file.failWrite(out);
|
||||
Log.w(TAG, "Queue not saved: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tick ----------
|
||||
|
||||
void tick(EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session, UploadSettings settings) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
|
||||
Integer soc = vehicle.getSocPercent() == null ? null : Math.round(vehicle.getSocPercent());
|
||||
Float powerKw = vehicle.getBatteryPowerKw();
|
||||
Boolean parked = vehicle.getParked();
|
||||
// The charge port property need not be implemented; ChargingSignal falls back to the
|
||||
// charge rate and the park state, exactly as the removed ABRP uploader did.
|
||||
Boolean charging = chargingSignal.evaluate(
|
||||
nowMs, vehicle.getChargePortConnected(), powerKw == null ? null : -powerKw, parked, soc);
|
||||
Boolean dcfc = charging == null || powerKw == null ? null
|
||||
: charging && ChargingState.isDcCharging(vehicle.getChargingStatus(),
|
||||
vehicle.getChargePortConnected(), powerKw, vehicle.getSpeedKmh());
|
||||
|
||||
boolean stateChanged = !Objects.equals(parked, lastParked) || !Objects.equals(charging, lastCharging);
|
||||
lastParked = parked;
|
||||
lastCharging = charging;
|
||||
|
||||
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
|
||||
if (idle) {
|
||||
// Parked and idle the car does not move: keep one fresh fix and stop the GPS until
|
||||
// the car is in use again. Without a fix yet (just started, or in a garage) it
|
||||
// keeps looking.
|
||||
if (parkedLocation == null) parkedLocation = freshLocation(settings);
|
||||
if (parkedLocation == null) ensureLocationUpdates(settings);
|
||||
else if (locationWanted) stop();
|
||||
} else {
|
||||
parkedLocation = null;
|
||||
ensureLocationUpdates(settings);
|
||||
}
|
||||
boolean uploadDue = UploadCadence.shouldUpload(
|
||||
nowMs, lastUploadAttemptMs, parked, charging, soc, stateChanged, settings);
|
||||
// Before the head unit has set its clock the sample's time is meaningless, and the
|
||||
// server would drop it anyway.
|
||||
boolean clockSet = TelemetryPayload.isPlausibleUtc(nowMs / 1000);
|
||||
if (clockSet && (!idle || uploadDue)) {
|
||||
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked, idle));
|
||||
}
|
||||
if (!uploadDue) {
|
||||
maybeSave();
|
||||
return;
|
||||
}
|
||||
// Cadence counts attempts, not successes: retrying every tick while offline would
|
||||
// spend power exactly when it buys nothing.
|
||||
lastUploadAttemptMs = nowMs;
|
||||
flush();
|
||||
maybeSave();
|
||||
}
|
||||
|
||||
int queued() { return queue.size(); }
|
||||
|
||||
// ---------- Sample ----------
|
||||
|
||||
private String buildSample(long nowMs, EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session,
|
||||
UploadSettings settings, Integer soc, Boolean charging, Boolean dcfc, Boolean parked,
|
||||
boolean idle) {
|
||||
TelemetryPayload tlm = new TelemetryPayload(nowMs / 1000);
|
||||
tlm.soc = soc;
|
||||
tlm.speedKmh = vehicle.getSpeedKmh();
|
||||
tlm.rangeKm = vehicle.getRangeKm() == null ? null : Math.round(vehicle.getRangeKm());
|
||||
tlm.powerKw = vehicle.getBatteryPowerKw();
|
||||
tlm.charging = charging;
|
||||
tlm.dcfc = dcfc;
|
||||
tlm.parked = parked;
|
||||
tlm.cabinTemp = vehicle.getCabinTempCelsius();
|
||||
tlm.battTempC = vehicle.getBatteryTempCelsius();
|
||||
tlm.extTempC = vehicle.getOutsideTempCelsius();
|
||||
tlm.capacityKwh = vehicle.getBatteryCapacityKwh();
|
||||
tlm.soeKwh = vehicle.getBatteryEnergyKwh();
|
||||
tlm.odometerKm = vehicle.getOdometerKm();
|
||||
tlm.hvacSetpointC = vehicle.getClimate().getDriverTargetCelsius();
|
||||
tlm.voltageV = vehicle.getBatteryVoltageV();
|
||||
tlm.currentA = vehicle.getBatteryCurrentA();
|
||||
// Only DC charges are metered, so only those carry an energy figure.
|
||||
if (Boolean.TRUE.equals(dcfc) && session != null && session.charging) tlm.kwhCharged = session.energyKwh;
|
||||
TirePressureSnapshot tires = vehicle.getTirePressures();
|
||||
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
|
||||
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
|
||||
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
|
||||
tlm.tirePressureRrKpa = tires.getRearRightKpa();
|
||||
Location loc = idle ? parkedLocation : freshLocation(settings);
|
||||
if (loc != null) {
|
||||
tlm.lat = loc.getLatitude();
|
||||
tlm.lon = loc.getLongitude();
|
||||
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
|
||||
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
|
||||
}
|
||||
return tlm.build();
|
||||
}
|
||||
|
||||
// ---------- Upload ----------
|
||||
|
||||
private void flush() {
|
||||
// ABRP first and on its own: live data must not wait for, or fail with, the server.
|
||||
sendNewestToAbrp();
|
||||
ServerConfig config = ServerConfig.get(context);
|
||||
if (!config.isConfigured()) {
|
||||
// Kept for the server once one is set up; the queue is bounded.
|
||||
Log.i(TAG, "Telemetry not sent to a server: none configured (" + queue.size() + " queued)");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < MAX_BATCHES_PER_TICK && queue.size() > 0; i++) {
|
||||
List<String> batch = queue.peekBatch();
|
||||
String summary = TelemetryPayload.summarize(batch.get(batch.size() - 1));
|
||||
try {
|
||||
ServerHttp.Response response = post(config, batch);
|
||||
int status = response.status;
|
||||
boolean ok = response.ok();
|
||||
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), status, ok,
|
||||
ok ? "Telemetry: " + batch.size() + " sample(s) sent" + droppedNote(response)
|
||||
: "Telemetry rejected (" + batch.size() + " queued): " + response.excerpt(), summary));
|
||||
if (!ok) {
|
||||
// The server drops bad values itself and answers 200, so a 400 is a request
|
||||
// it cannot read at all and will never take: drop the batch rather than
|
||||
// block the queue behind it forever. Anything else (401, 403, 5xx) is kept.
|
||||
if (status == 400) queue.remove(batch.size());
|
||||
return;
|
||||
}
|
||||
queue.remove(batch.size());
|
||||
} catch (Exception e) {
|
||||
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
|
||||
"Telemetry offline — " + queue.size() + " queued: " + e.getClass().getSimpleName(),
|
||||
summary));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ServerHttp.Response post(ServerConfig config, List<String> batch) throws Exception {
|
||||
JSONArray samples = new JSONArray();
|
||||
for (String sample : batch) samples.put(new JSONObject(sample));
|
||||
JSONObject body = new JSONObject()
|
||||
.put("installationId", config.installationId)
|
||||
.put("samples", samples);
|
||||
return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body);
|
||||
}
|
||||
|
||||
// ---------- ABRP, directly ----------
|
||||
|
||||
/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */
|
||||
private static final long ABRP_MAX_AGE_S = 5 * 60;
|
||||
|
||||
/** Time of the newest sample ABRP accepted from the car; 0 before the first. */
|
||||
private volatile long lastAbrpUtc;
|
||||
|
||||
/**
|
||||
* Sends the newest queued sample straight to ABRP when ABRP credentials are set: once per
|
||||
* upload, only if it is newer than the last one sent and still live. It stays queued for
|
||||
* the server either way; the server does not forward to ABRP.
|
||||
*/
|
||||
private void sendNewestToAbrp() {
|
||||
AbrpConfig abrp = AbrpConfig.get(context);
|
||||
if (!abrp.isConfigured()) return;
|
||||
String newest = queue.newest();
|
||||
if (newest == null) return;
|
||||
long utc = utcOf(newest);
|
||||
if (utc <= lastAbrpUtc || System.currentTimeMillis() / 1000 - utc > ABRP_MAX_AGE_S) return;
|
||||
String summary = TelemetryPayload.summarize(newest);
|
||||
try {
|
||||
ServerHttp.Response response = AbrpApi.send(abrp, newest);
|
||||
boolean ok = AbrpApi.accepted(response);
|
||||
if (ok) lastAbrpUtc = utc;
|
||||
// ABRP's answer is not quoted: it can echo the request, credentials included.
|
||||
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), response.status, ok,
|
||||
ok ? "ABRP: live data sent" : "ABRP rejected: HTTP " + response.status, summary));
|
||||
} catch (Exception e) {
|
||||
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
|
||||
"ABRP offline: " + e.getClass().getSimpleName(), summary));
|
||||
}
|
||||
}
|
||||
|
||||
static long utcOf(String sampleJson) {
|
||||
try {
|
||||
return new JSONObject(sampleJson).optLong("utc", 0L);
|
||||
} catch (Exception e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** ", 2 value(s) dropped by the server" when it left anything out, for the upload log. */
|
||||
static String droppedNote(ServerHttp.Response response) {
|
||||
try {
|
||||
int dropped = new JSONObject(response.body).optInt("droppedCount", 0);
|
||||
return dropped > 0 ? ", " + dropped + " value(s) dropped by the server" : "";
|
||||
} catch (Exception e) {
|
||||
return ""; // an older server answers 204 with no body
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Location ----------
|
||||
|
||||
private final LocationListener locationListener = new LocationListener() {
|
||||
@Override public void onLocationChanged(Location location) { lastLocation = location; }
|
||||
@Override public void onStatusChanged(String provider, int status, Bundle extras) { }
|
||||
@Override public void onProviderEnabled(String provider) { locationActive = false; }
|
||||
@Override public void onProviderDisabled(String provider) {
|
||||
// A stale fix is worse than none: it would put the car back where it was.
|
||||
lastLocation = null;
|
||||
locationActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribes to GPS once, and again after a failure or a provider change. */
|
||||
private void ensureLocationUpdates(UploadSettings settings) {
|
||||
locationWanted = true;
|
||||
if (locationActive) return;
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (lastLocationRequestMs != 0L && now - lastLocationRequestMs < LOCATION_RETRY_MS) return;
|
||||
lastLocationRequestMs = now;
|
||||
new android.os.Handler(Looper.getMainLooper()).post(() -> requestLocationUpdates(settings));
|
||||
}
|
||||
|
||||
private void requestLocationUpdates(UploadSettings settings) {
|
||||
if (!locationWanted) return; // stopped while this request was queued
|
||||
try {
|
||||
if (locationManager == null) {
|
||||
locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
|
||||
}
|
||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) { }
|
||||
if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
|
||||
Log.w(TAG, "GPS provider disabled — position omitted, will retry");
|
||||
return;
|
||||
}
|
||||
// Matched to the upload interval: fixes between uploads would only be thrown away.
|
||||
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
|
||||
Math.min(settings.intervalMs(), 15_000L), 0f, locationListener, Looper.getMainLooper());
|
||||
locationActive = true;
|
||||
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
|
||||
if (last != null && locationAgeMs(last) <= MIN_LOCATION_MAX_AGE_MS) lastLocation = last;
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Location permission not granted: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Location unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the GPS subscription; the next tick that needs a position starts it again. */
|
||||
void stop() {
|
||||
locationWanted = false;
|
||||
try {
|
||||
if (locationManager != null) locationManager.removeUpdates(locationListener);
|
||||
} catch (Exception ignored) { }
|
||||
locationActive = false;
|
||||
lastLocationRequestMs = 0L;
|
||||
}
|
||||
|
||||
private Location freshLocation(UploadSettings settings) {
|
||||
Location loc = lastLocation;
|
||||
if (loc == null) return null;
|
||||
long maxAge = Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
|
||||
return locationAgeMs(loc) <= maxAge ? loc : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Age on the monotonic clock: the head unit sets its wall clock from GPS, so a fix's
|
||||
* wall-clock time can jump. A provider that does not stamp the monotonic clock leaves 0;
|
||||
* such a fix counts as fresh rather than blanking the position for good.
|
||||
*/
|
||||
private static long locationAgeMs(Location loc) {
|
||||
long fixNanos = loc.getElapsedRealtimeNanos();
|
||||
if (fixNanos <= 0L) return 0L;
|
||||
return Math.max(0L, (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Decides whether a given scheduler tick should actually upload.
|
||||
*
|
||||
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
|
||||
* overnight and not charging does not need a sample every minute — that is wasted data and
|
||||
* power — but nothing may be lost while driving or charging, where ABRP needs the
|
||||
* resolution to keep a route plan accurate.
|
||||
*
|
||||
* Free of Android types so the policy can be unit-tested directly.
|
||||
*/
|
||||
final class UploadCadence {
|
||||
|
||||
/**
|
||||
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
|
||||
* moving and its SOC is not changing, so anything more is spent for nothing.
|
||||
*/
|
||||
static final long PARKED_INTERVAL_MS = 900_000L;
|
||||
|
||||
private UploadCadence() { }
|
||||
|
||||
/**
|
||||
* @param nowMs current time
|
||||
* @param lastUploadMs when the last upload was attempted, 0 if never
|
||||
* @param parked null when the gear could not be read
|
||||
* @param charging null when the charge port state could not be read
|
||||
* @param socPercent null when the battery level could not be read
|
||||
* @param stateChanged true if parked/charging differ from the previous tick
|
||||
* @param settings user-configured cadence
|
||||
*/
|
||||
static boolean shouldUpload(long nowMs, long lastUploadMs,
|
||||
Boolean parked, Boolean charging, Integer socPercent,
|
||||
boolean stateChanged, UploadSettings settings) {
|
||||
// Never uploaded yet: send immediately so ABRP sees the car come online.
|
||||
if (lastUploadMs <= 0L) return true;
|
||||
|
||||
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
|
||||
// learn straight away — never make it wait for the slow interval.
|
||||
if (stateChanged) return true;
|
||||
|
||||
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval that applies in the given state.
|
||||
*
|
||||
* Unknown state counts as active: if the gear or charge port could not be read we do
|
||||
* not know the car is idle, and under-reporting a moving car is worse than a few
|
||||
* extra requests from a stationary one.
|
||||
*/
|
||||
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
|
||||
UploadSettings settings) {
|
||||
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
|
||||
if (idle) return PARKED_INTERVAL_MS;
|
||||
|
||||
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
|
||||
// curve to get the arrival estimate right. Only while actually in use — a car
|
||||
// parked at 5 % is still parked and gains nothing from fast polling.
|
||||
if (settings.boostLowSoc && socPercent != null
|
||||
&& socPercent <= settings.lowSocPercent) {
|
||||
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
|
||||
}
|
||||
return settings.intervalMs();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,92 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
|
||||
* there is no user-configurable upload policy left. Kept as an empty shell so any stray
|
||||
* references compile during the ABRP removal; will be deleted once all callers are gone.
|
||||
* User-configurable upload policy.
|
||||
*
|
||||
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
|
||||
* and both the upload tick and the GPS subscription cost power the whole time, so the
|
||||
* default cadence is the slowest one that still keeps an ABRP route plan usable.
|
||||
*/
|
||||
final class UploadSettings {
|
||||
|
||||
static final String KEY_AUTOSTART = "autostart";
|
||||
static final boolean DEFAULT_AUTOSTART = true;
|
||||
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
|
||||
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
|
||||
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
|
||||
static final String KEY_AUTOSTART = "autostart";
|
||||
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
|
||||
/** Hide the head unit's system bars while the app is open. */
|
||||
static final String KEY_FULL_SCREEN = "full_screen";
|
||||
|
||||
private UploadSettings() { }
|
||||
static boolean fullScreen(SharedPreferences prefs) {
|
||||
return prefs.getBoolean(KEY_FULL_SCREEN, true);
|
||||
}
|
||||
|
||||
/** A double press of the steering wheel's voice key opens the app. */
|
||||
static final String KEY_VOICE_KEY_OPENS = "voice_key_opens_app";
|
||||
|
||||
static boolean voiceKeyOpensApp(SharedPreferences prefs) {
|
||||
return prefs.getBoolean(KEY_VOICE_KEY_OPENS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
|
||||
* starts, and nobody opens this app before setting off. Kept as a switch because a
|
||||
* service that starts itself must also be switchable off.
|
||||
*/
|
||||
static final boolean DEFAULT_AUTOSTART = true;
|
||||
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
|
||||
|
||||
/**
|
||||
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
|
||||
* speed, which ABRP can still track; going much slower makes the live plan lag
|
||||
* reality. Parked and idle backs off far further on its own — see UploadCadence.
|
||||
*/
|
||||
static final int DEFAULT_INTERVAL_SEC = 60;
|
||||
|
||||
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
|
||||
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
|
||||
|
||||
static final boolean DEFAULT_BOOST_LOW_SOC = true;
|
||||
|
||||
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
|
||||
static final int DEFAULT_LOW_SOC_PERCENT = 20;
|
||||
|
||||
/** Cadence used while SOC is low and the boost is enabled. */
|
||||
static final int BOOSTED_INTERVAL_SEC = 15;
|
||||
|
||||
final int intervalSec;
|
||||
final boolean boostLowSoc;
|
||||
final int lowSocPercent;
|
||||
final boolean abrpUploadEnabled;
|
||||
|
||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
|
||||
this(intervalSec, boostLowSoc, lowSocPercent, DEFAULT_ABRP_UPLOAD_ENABLED);
|
||||
}
|
||||
|
||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent,
|
||||
boolean abrpUploadEnabled) {
|
||||
this.intervalSec = intervalSec;
|
||||
this.boostLowSoc = boostLowSoc;
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
this.abrpUploadEnabled = abrpUploadEnabled;
|
||||
}
|
||||
|
||||
static UploadSettings from(SharedPreferences prefs) {
|
||||
return new UploadSettings(
|
||||
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
|
||||
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
|
||||
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT),
|
||||
prefs.getBoolean(KEY_ABRP_UPLOAD_ENABLED, DEFAULT_ABRP_UPLOAD_ENABLED));
|
||||
}
|
||||
|
||||
static UploadSettings defaults() {
|
||||
return new UploadSettings(
|
||||
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
|
||||
}
|
||||
|
||||
long intervalMs() {
|
||||
return intervalSec * 1000L;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiConfiguration;
|
||||
import android.net.wifi.WifiInfo;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Brings Wi-Fi up and joins a network the head unit already knows.
|
||||
*
|
||||
* The point is the phone hotspot: the car has the credentials saved, but the driver still
|
||||
* has to walk into Settings and tap the network every time. This does that tap.
|
||||
*
|
||||
* <b>Why this works here and would not on a newer phone.</b> {@code getConfiguredNetworks}
|
||||
* and {@code enableNetwork} were deprecated in API 29 and, from that release, return an
|
||||
* empty list and refuse the call for ordinary apps — Android handed network choice to the
|
||||
* user through a system picker. This head unit runs Android 9 (API 28), where both are
|
||||
* still honoured, and the build is platform-signed, so it is the same UID family the
|
||||
* Settings app itself uses. Neither fact is portable: on a Pixel this class would find
|
||||
* nothing and change nothing. It is deliberately written to fail quietly if so.
|
||||
*
|
||||
* Nothing here creates or edits a network. It only enables one the unit was already
|
||||
* configured with, which is why no password ever passes through this app.
|
||||
*/
|
||||
final class WifiAutoConnect {
|
||||
|
||||
private static final String TAG = "EVABRP.Wifi";
|
||||
|
||||
/** Re-checking faster than this achieves nothing: a join takes seconds to settle. */
|
||||
private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L;
|
||||
|
||||
private final WifiManager wifi;
|
||||
private long lastAttemptMs;
|
||||
|
||||
WifiAutoConnect(Context context) {
|
||||
this.wifi = (WifiManager) context.getApplicationContext()
|
||||
.getSystemService(Context.WIFI_SERVICE);
|
||||
}
|
||||
|
||||
/** The SSID the unit is associated with, or null when it is not on Wi-Fi. */
|
||||
String currentSsid() {
|
||||
try {
|
||||
if (wifi == null || !wifi.isWifiEnabled()) return null;
|
||||
WifiInfo info = wifi.getConnectionInfo();
|
||||
if (info == null) return null;
|
||||
String ssid = info.getSSID();
|
||||
if (ssid == null) return null;
|
||||
// The framework quotes the SSID and reports this placeholder when it will not
|
||||
// say — usually because location is off. Neither is a network name.
|
||||
ssid = ssid.replace("\"", "").trim();
|
||||
if (ssid.isEmpty() || "<unknown ssid>".equals(ssid) || "0x".equals(ssid)) return null;
|
||||
return ssid;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isWifiEnabled() {
|
||||
try {
|
||||
return wifi != null && wifi.isWifiEnabled();
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */
|
||||
List<String> savedNetworkNames() {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
try {
|
||||
if (wifi == null) return names;
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null) return names;
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null || c.SSID == null) continue;
|
||||
String name = c.SSID.replace("\"", "").trim();
|
||||
if (!name.isEmpty() && !names.contains(name)) names.add(name);
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Saved networks unavailable: permission denied");
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt to get onto a saved network, rate-limited.
|
||||
*
|
||||
* @param force ignore the rate limit — for the button, which a driver only presses
|
||||
* because the automatic attempt has not worked yet.
|
||||
* @return what happened, for the log and the UI. Never null.
|
||||
*/
|
||||
Result ensureConnected(boolean force) {
|
||||
if (wifi == null) return Result.UNSUPPORTED;
|
||||
|
||||
String ssid = currentSsid();
|
||||
if (ssid != null) return Result.alreadyOn(ssid);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING;
|
||||
lastAttemptMs = now;
|
||||
|
||||
try {
|
||||
if (!wifi.isWifiEnabled()) {
|
||||
// Also gone for ordinary apps from API 29, and also still honoured here.
|
||||
if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE;
|
||||
// The radio needs a moment before it will associate; the next tick retries.
|
||||
return Result.ENABLING;
|
||||
}
|
||||
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS;
|
||||
|
||||
// A scan would let us prefer a network that is actually in range, but the results
|
||||
// are themselves throttled and location-gated. Asking the supplicant to pick from
|
||||
// what it knows is both simpler and what the Settings screen effectively does.
|
||||
wifi.reconnect();
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null) continue;
|
||||
// Second argument true: disable the others for this attempt, so the
|
||||
// supplicant commits to this one rather than drifting between them.
|
||||
if (wifi.enableNetwork(c.networkId, true)) {
|
||||
String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim();
|
||||
Log.i(TAG, "enableNetwork accepted for " + name);
|
||||
return Result.attempting(name);
|
||||
}
|
||||
}
|
||||
return Result.REFUSED;
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Wi-Fi connection refused: permission denied");
|
||||
return Result.REFUSED;
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "connect attempt failed: " + t.getMessage());
|
||||
return Result.REFUSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of one attempt. {@link #connected} is the only one that means we are online. */
|
||||
static final class Result {
|
||||
final boolean connected;
|
||||
final String ssid;
|
||||
final String message;
|
||||
|
||||
private Result(boolean connected, String ssid, String message) {
|
||||
this.connected = connected;
|
||||
this.ssid = ssid;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); }
|
||||
static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); }
|
||||
|
||||
static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor");
|
||||
static final Result WAITING = new Result(false, null, "Bekleniyor…");
|
||||
static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…");
|
||||
static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı");
|
||||
static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok");
|
||||
static final Result REFUSED = new Result(false, null, "Sistem izin vermedi");
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<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 @@
|
||||
<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,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,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>
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Wi-Fi arcs, for the Wi-Fi page. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="92dp" android:height="180dp" android:viewportWidth="92" android:viewportHeight="180">
|
||||
<path android:pathData="M27,12 Q46,5 65,12 Q74,18 74,38 L78,130 Q79,157 64,164 L28,164 Q13,157 14,130 L18,38 Q18,18 27,12Z" android:fillColor="#151D1E" android:strokeColor="#85BDBF" android:strokeWidth="1.4"/>
|
||||
<path android:pathData="M24,45 Q46,37 68,45 L63,65 Q46,61 29,65Z M28,129 Q46,135 64,129 L66,148 Q46,155 26,148Z M28,72 L28,122 M64,72 L64,122 M23,28 L35,25 M57,25 L69,28 M23,157 L35,159 M57,159 L69,157" android:fillColor="#102D30" android:strokeColor="#527B7C" android:strokeWidth="1.3"/>
|
||||
<path android:pathData="M10,43 L10,67 M82,43 L82,67 M10,120 L10,144 M82,120 L82,144" android:fillColor="#00000000" android:strokeColor="#D8C39A" android:strokeWidth="5" android:strokeLineCap="round"/>
|
||||
<path android:pathData="M27,12 Q46,5 65,12 Q74,18 74,38 L78,130 Q79,157 64,164 L28,164 Q13,157 14,130 L18,38 Q18,18 27,12Z" android:fillColor="?attr/evAccentSurface" android:strokeColor="?attr/evAccentLight" android:strokeWidth="1.4"/>
|
||||
<path android:pathData="M24,45 Q46,37 68,45 L63,65 Q46,61 29,65Z M28,129 Q46,135 64,129 L66,148 Q46,155 26,148Z M28,72 L28,122 M64,72 L64,122 M23,28 L35,25 M57,25 L69,28 M23,157 L35,159 M57,159 L69,157" android:fillColor="?attr/evAccentDeep" android:strokeColor="?attr/evAccentStroke" android:strokeWidth="1.3"/>
|
||||
<path android:pathData="M10,43 L10,67 M82,43 L82,67 M10,120 L10,144 M82,120 L82,144" android:fillColor="#00000000" android:strokeColor="?attr/evAccent" android:strokeWidth="5" android:strokeLineCap="round"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,25 +4,23 @@
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal">
|
||||
<ScrollView android:layout_width="192dp" android:layout_height="match_parent" android:layout_margin="16dp" android:fillViewport="true" android:background="@drawable/bg_sidebar" android:scrollbars="none">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="@color/ev_accent" android:paddingStart="14dp" android:paddingTop="8dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent" android:paddingStart="14dp" android:paddingTop="8dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Secondary" android:paddingStart="14dp" android:layout_marginBottom="24dp" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_charging" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_live" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabSettings" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_settings" app:icon="@drawable/ic_tab_service" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="8dp">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:id="@+id/header_title" android:textSize="32sp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:id="@+id/header_subtitle" android:layout_marginTop="6dp" />
|
||||
</LinearLayout>
|
||||
<TextView android:id="@+id/version_badge" style="@style/Text.EV.VersionBadge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/update_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/update_check" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="72dp" android:layout_height="72dp" android:layout_marginStart="8dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/about_action" app:icon="@drawable/ic_info" app:iconGravity="textStart" app:iconPadding="0dp" />
|
||||
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
|
||||
layouts). Version, update check and About are on the Settings tab. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="true" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="10dp" android:paddingBottom="2dp">
|
||||
<TextView android:id="@+id/header_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:textSize="24sp" android:maxLines="1" />
|
||||
<TextView android:id="@+id/header_subtitle" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_abrp_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<Spinner android:id="@+id/interval_spinner" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:layout_marginTop="12dp" android:background="@drawable/bg_input_panel" android:popupBackground="@color/ev_surface_raised" android:contentDescription="@string/label_frequency" android:spinnerMode="dropdown" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_frequency" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/boost_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_boost_low_soc" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/low_soc_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/label_low_soc_threshold">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/low_soc_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="number" android:maxLength="2" android:textSize="20sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/interval_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_frequency" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/boost_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_boost_low_soc" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/low_soc_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/label_low_soc_threshold">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/low_soc_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="number" android:maxLength="2" android:textSize="20sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</com.evsuite.abrp.StableScrollView>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<!-- Three columns: display and steering wheel | battery | charging price and the app. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_display_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_full_screen" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_soh_value"/>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/settings_soh_note" android:textAppearance="@style/Text.EV.Secondary"/>
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/hint_usable_capacity" app:suffixText="@string/unit_kwh">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/btn_save_capacity"/>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_price"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_app_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:id="@+id/settings_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/settings_update_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_check_update" app:icon="@drawable/ic_refresh" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/settings_usb_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_usb_update" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/settings_about_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_about" app:icon="@drawable/ic_info" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.evsuite.abrp.StableScrollView>
|
||||
@@ -1,6 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
|
||||
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_range_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_range_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_range_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_energy_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_trip" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_usage_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_usage_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_usage_average" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_usage_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_usage_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power_average" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_power_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_power_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed_average" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_speed_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_speed_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<Space android:layout_width="1dp" android:layout_height="16dp" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="20dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_energy" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="12dp" />
|
||||
@@ -17,45 +75,25 @@
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_tires" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="224dp" android:orientation="horizontal" android:gravity="center_vertical">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="196dp" android:orientation="horizontal" android:gravity="center_vertical">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
|
||||
<TextView android:id="@+id/vehicle_tire_fl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
|
||||
<TextView android:id="@+id/vehicle_tire_fl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
|
||||
<Space android:layout_width="1dp" android:layout_height="44dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rl" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
|
||||
<TextView android:id="@+id/vehicle_tire_rl" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
|
||||
<TextView android:id="@+id/vehicle_tire_rl" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
|
||||
</LinearLayout>
|
||||
<ImageView android:layout_width="92dp" android:layout_height="180dp" android:src="@drawable/ic_vehicle_outline" android:contentDescription="@string/design_car_desc" />
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_fr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
|
||||
<TextView android:id="@+id/vehicle_tire_fr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
|
||||
<TextView android:id="@+id/vehicle_tire_fr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
|
||||
<Space android:layout_width="1dp" android:layout_height="44dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_tire_rr" android:textAppearance="@style/Text.EV.Secondary" android:gravity="center" />
|
||||
<TextView android:id="@+id/vehicle_tire_rr" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="@color/ev_cyan" />
|
||||
<TextView android:id="@+id/vehicle_tire_rr" android:fontFeatureSettings="tnum" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Body" android:gravity="center" android:layout_marginTop="8dp" android:textColor="?attr/evAccentLight" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_no_data" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<Space android:layout_width="1dp" android:layout_height="16dp" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
<TextView android:id="@+id/vehicle_charging" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</com.evsuite.abrp.StableScrollView>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1.3" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:id="@+id/wifi_ssid" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Metric" android:layout_marginTop="20dp" />
|
||||
<TextView android:id="@+id/wifi_state" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="—" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="12dp" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/wifi_connect_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_connect_now" android:layout_marginTop="24dp" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/wifi_auto_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/wifi_auto_label" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_auto_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_saved_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:id="@+id/wifi_saved_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:textAppearance="@style/Text.EV.Body" android:lineSpacingExtra="16dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -4,14 +4,14 @@
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_title" android:textAppearance="@style/Text.EV.Eyebrow" android:layout_marginBottom="24dp"/>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="32dp" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="@color/ev_accent"/>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent"/>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Title" android:layout_marginTop="12dp"/>
|
||||
<TextView android:id="@+id/about_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="8dp"/>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/about_attribution" android:textAppearance="@style/Text.EV.Secondary" android:lineSpacingMultiplier="1.3" android:layout_marginTop="24dp"/>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="240dp" android:layout_height="wrap_content" android:orientation="vertical">
|
||||
<ImageView android:id="@+id/about_qr_code" android:layout_width="160dp" android:layout_height="160dp" android:layout_gravity="center_horizontal" android:background="#FFFFFF" android:padding="8dp" android:scaleType="fitCenter" android:contentDescription="@string/about_qr_description"/>
|
||||
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_cyan" android:paddingTop="12dp" android:paddingBottom="12dp"/>
|
||||
<TextView android:id="@+id/about_repository" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:gravity="center" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:text="@string/about_repository" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:paddingTop="12dp" android:paddingBottom="12dp"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/ev_outline" android:layout_marginTop="24dp" android:layout_marginBottom="12dp"/>
|
||||
|
||||
@@ -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,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"/>
|
||||
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user