Compare commits
80
Commits
8f49871e6f
...
f54e684ae1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f54e684ae1 | ||
|
|
253c768781 | ||
|
|
69d284f81b | ||
|
|
0fd024cc2e | ||
|
|
5a82fe3dc1 | ||
|
|
5ead905e5f | ||
|
|
224840bde2 | ||
|
|
d4bb502026 | ||
|
|
851a4a34d4 | ||
|
|
8d3eb80f66 | ||
|
|
79bcfa05f6 | ||
|
|
f614ab08be | ||
|
|
98ec3f3fcf | ||
|
|
bfa33d51bd | ||
|
|
c2c0640672 | ||
|
|
a293a0fa94 | ||
|
|
c71fa4504e | ||
|
|
9226616261 | ||
|
|
e5549dddb0 | ||
|
|
f88249427f | ||
|
|
f63f48131e | ||
|
|
29064cf8db | ||
|
|
5e2e354bdd | ||
|
|
727da51a3c | ||
|
|
010a0cd50d | ||
|
|
9b5fcc0734 | ||
|
|
40906ec4a7 | ||
|
|
66fed48aae | ||
|
|
d711e0cfda | ||
|
|
9f184362f0 | ||
|
|
ca2f899403 | ||
|
|
087f193f8c | ||
|
|
3ae1e18a43 | ||
|
|
2a0c4c9675 | ||
|
|
a5db5604d3 | ||
|
|
9bebf56b3a | ||
|
|
4b858e3c35 | ||
|
|
1b918e3c39 | ||
|
|
8bad5fc024 | ||
|
|
3f1e196817 | ||
|
|
55710fb722 | ||
|
|
af15b15db1 | ||
|
|
fde963533d | ||
|
|
11d53e7bc7 | ||
|
|
a53ea589b2 | ||
|
|
5f6cf1e901 | ||
|
|
c5d61d1d11 | ||
|
|
582c084b35 | ||
|
|
f3de272c69 | ||
|
|
cee0eaeab3 | ||
|
|
756ae65d0b | ||
|
|
84fb3aac21 | ||
|
|
46dccee1de | ||
|
|
8a5813fa16 | ||
|
|
6f57351866 | ||
|
|
315586f94c | ||
|
|
59ee2cae3b | ||
|
|
c578a882bd | ||
|
|
d5f68e1bd9 | ||
|
|
4fe29189c0 | ||
|
|
e874578e54 | ||
|
|
5473ab1c2b | ||
|
|
51e6f6fd48 | ||
|
|
538d03361d | ||
|
|
818442db08 | ||
|
|
e39d9cdcf9 | ||
|
|
6145736c97 | ||
|
|
c253876ed2 | ||
|
|
4ea66cf424 | ||
|
|
d4fb4ba3af | ||
|
|
de2428786a | ||
|
|
10029623e3 | ||
|
|
b2a5b32b08 | ||
|
|
a707b590f7 | ||
|
|
3da3fa9f49 | ||
|
|
3af337ca89 | ||
|
|
8e7d8b8627 | ||
|
|
9d647f2ce1 | ||
|
|
bac887bfa6 | ||
|
|
864ac612da |
@@ -39,3 +39,7 @@ mise.local.toml
|
|||||||
/EVHardware/build
|
/EVHardware/build
|
||||||
/EVHardware/.gradle
|
/EVHardware/.gradle
|
||||||
/EVHardware/**/build
|
/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.
|
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
|
||||||
EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative
|
(status, to-dos, a dated log of every change) and is updated with each change.
|
||||||
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.
|
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and
|
An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to:
|
||||||
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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP
|
MG4 head unit (Android 9, AAOS, MT2712)
|
||||||
CarPropertyAdapter reflection bridge to android.car (not on the compile classpath)
|
app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana
|
||||||
TelemetryPayload pure JSON building — no Android types, unit-tested
|
│ │ relays app releases from the forge
|
||||||
UploadCadence when a tick actually uploads — pure, unit-tested
|
└──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner)
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
|
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
|
||||||
anything inside the service effectively cannot be tested without a car.
|
`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 |
|
## Rules
|
||||||
|---|---|---|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
The stable channel does not merely disable self-update — the updater is **not in the
|
1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner
|
||||||
APK**.
|
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
|
- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
|
||||||
differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing
|
about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
|
||||||
must tolerate a property simply not existing.
|
- 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 |
|
| Tab | Contents |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally |
|
| 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 |
|
||||||
| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them |
|
| 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
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
the generation and picks the right underlying API. Fields it covers therefore work on every
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
supported firmware.
|
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
|
**Put logic in pure, unit-tested classes** (no Android types); the service and activity are
|
||||||
`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware
|
hard to test without a car.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB
|
||||||
mise run check # what CI runs: permission gate + lint + unit tests
|
install). The car always gets `unstable`.
|
||||||
mise run build
|
|
||||||
mise run logs # adb logcat, filtered to this app
|
## 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`,
|
- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with
|
||||||
a stale Gradle daemon started by VS Code's Java extension is being reused —
|
every build that goes to the car (a lower code is refused as a downgrade).
|
||||||
`pkill -f GradleDaemon` and retry.
|
- **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.
|
| File | What |
|
||||||
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
|
| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log |
|
||||||
implementation is added as a test dependency; do not remove it.
|
| `README.md` | user-facing overview, install, setup, config file |
|
||||||
- **`service_running` in preferences goes stale-true** after a force-kill. Use
|
| `OTA.md` | releases, the updater's checks, USB install |
|
||||||
`AbrpUploadService.isRunning()`.
|
| `FIRMWARE.md` | the head unit, firmware generations, display |
|
||||||
- **Permission changes fail CI** until allowlisted with a justification.
|
| `server/README.md`, `k8s/README.md` | API, configuration, deployment |
|
||||||
|
| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain |
|
||||||
## Honesty requirements
|
| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history |
|
||||||
|
|
||||||
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 -->
|
|
||||||
|
|||||||
+2
-2
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
|
- 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
|
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.
|
the previous DC session's figures next to it.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- 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
|
`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
|
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
|
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
|
to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the
|
||||||
same problem on the same head unit.
|
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
|
## Licensing
|
||||||
|
|
||||||
Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because
|
Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence,
|
||||||
the upstream fork source has none. Contributions are accepted on the understanding that
|
except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence.
|
||||||
they are offered under MIT once that is settled.
|
|
||||||
|
|||||||
@@ -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
|
* CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the
|
||||||
* property revient vide, et l'état d'allumage passait pour illisible alors que Katman5
|
* property comes back empty, and the ignition state looked unreadable although Katman5
|
||||||
* le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`)
|
* reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while
|
||||||
* pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE.
|
* the diagnostics classed IGNITION_STATE as NOT_READABLE.
|
||||||
*
|
*
|
||||||
* D'où le repli sur le dernier état livré par IVehicleConditionService, converti :
|
* Hence the fallback to the last state delivered by IVehicleConditionService, converted:
|
||||||
* Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les
|
* Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two
|
||||||
* deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre).
|
* 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 {
|
fun getCurrentIgnitionState(): Int {
|
||||||
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
|
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
|
||||||
@@ -356,7 +356,7 @@ object EVHardware {
|
|||||||
return vcmIgnitionAsAaos() ?: global
|
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) {
|
private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) {
|
||||||
CarIgnitionItem.OFF -> IgnitionState.OFF
|
CarIgnitionItem.OFF -> IgnitionState.OFF
|
||||||
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
|
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** |
|
| Android | 9 (Pie) — **API 28** |
|
||||||
| Chipset | MediaTek **MT2712** |
|
| Chipset | MediaTek **MT2712** |
|
||||||
| Device | `mt2712_saic_eh32` (eh32 = MG4) |
|
| 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
|
## Signing caveat
|
||||||
|
|
||||||
@@ -42,5 +47,5 @@ follow the same path. See `AGENTS.md` for the full per-signal split.
|
|||||||
## Emulator fidelity
|
## Emulator fidelity
|
||||||
|
|
||||||
The `mise` emulator tasks use an API 33 automotive AVD for the car service and an API 28
|
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.
|
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 Leon Kernan (original ABRP_Uploader)
|
||||||
Copyright (c) 2026 EVABRPUploader contributors
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+41
-11
@@ -1,19 +1,49 @@
|
|||||||
# Licence
|
# 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
|
Nils' MG4 is a fork of a chain of forks, all MIT:
|
||||||
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.
|
|
||||||
|
|
||||||
The upstream repository carried no licence file of its own. Applying MIT here is the
|
- [Leon Kernan's `ABRP_Uploader`](https://github.com/lkernan/ABRP_Uploader), the original. It
|
||||||
project owner's decision; if Leon Kernan ever objects, the right response is to honour
|
carries the MIT Licence since 2026-07-29
|
||||||
that rather than argue about it.
|
([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
|
## No warranty
|
||||||
|
|
||||||
MIT disclaims all warranty and liability, and this software runs on a **vehicle** — see
|
Both licences disclaim all warranty and liability, and this software runs on a **vehicle** —
|
||||||
[`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely.
|
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
|
How a new car build reaches the head unit without the Bluetooth-settings detour, and what to
|
||||||
next — probably me, having forgotten the details.
|
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.
|
The repository stays private and the forge token stays on the server: the car asks its own
|
||||||
Those builds use the old `pm install` path; publishing a newer APK does not repair the
|
server, with its own token, and never holds a forge credential. The server only relays
|
||||||
installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions.
|
bytes. Every check that decides whether code gets installed is in the app.
|
||||||
Keep the unstable package ID, system shared UID and signing certificate when upgrading.
|
|
||||||
|
|
||||||
The 2.2.5 build removes window automation. It also distinguishes failed release checks
|
## Publishing a release
|
||||||
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`.
|
|
||||||
|
|
||||||
The vehicle artifact uses `com.evsuite.abrp.unstable`, `android.uid.system`, and the
|
1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName`
|
||||||
DriveHub_Kamera platform key. Its certificate SHA-256 must be
|
is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`.
|
||||||
`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`.
|
2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head
|
||||||
The stable build used for a Samsung UI preview is a separate package without OTA or the
|
unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-<version>.apk` and
|
||||||
vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
|
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`
|
## What the updater accepts
|
||||||
and walks **every** entry, keeping the highest version rather than the first match. A
|
|
||||||
release is a candidate only if all of these hold:
|
|
||||||
|
|
||||||
| 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 |
|
| Marked **pre-release**, not a draft | `selectUpdate` | Skipped |
|
||||||
| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored |
|
| Asset name contains `unstable` and ends `-<version>.apk` | `ASSET_VERSION` regex | Ignored |
|
||||||
| Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored |
|
| Version strictly higher than installed | `isNewer()` | "Up to date" |
|
||||||
| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" |
|
| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused |
|
||||||
| Download host on the allowlist | `isAllowedUrl()` | Refused and logged |
|
| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted |
|
||||||
| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform |
|
| The archive is this app's package | `archiveIsThisPackage()` | Refused |
|
||||||
| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/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
|
The version comes from the **asset file name**, not the tag.
|
||||||
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 filename**, not the git tag. The tag can stay
|
## Installing
|
||||||
`unstable` forever and be moved; the filename is what identifies a build.
|
|
||||||
|
|
||||||
## 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
|
## Installing from a USB stick
|
||||||
versionCode 24 // must increase — Android orders updates by this, not by name
|
|
||||||
versionName "2.2.7"
|
|
||||||
```
|
|
||||||
|
|
||||||
The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7.0-unstable`.
|
For development, without the forge: copy `NilsMG4-unstable-<version>.apk` **and** its `.sha256`
|
||||||
Check what the installed build reports before picking a number: the app's About
|
from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB
|
||||||
dialog shows it, and `isNewer` compares numerically segment by segment.
|
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.**
|
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.
|
||||||
```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.
|
|
||||||
|
|
||||||
## When it says something else
|
## 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. |
|
| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. |
|
||||||
| `Güncelleme reddedildi: SHA-256 doğrulaması yok` | APK has no matching sidecar, or the sidecar is invalid/unreachable. |
|
| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. |
|
||||||
| `İndirme veya SHA-256 doğrulaması başarısız` | Download failed or its bytes do not match the published hash. |
|
| `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. |
|
||||||
| `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. |
|
| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. |
|
||||||
| `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. |
|
| `Download or SHA-256 verification failed (…)` | The download broke off, or the bytes do not match the published hash. |
|
||||||
| `Kurulum reddedildi: APK okunamadı` | The archive does not parse, or it is built for another package. |
|
| `Update refused: the downloaded file is not this app` | The archive does not parse, or it is another package. |
|
||||||
| `Kurulum başlatılamadı … Log sayfasına bakın` | The session could not be created or committed. `adb logcat -s EVABRP.Update`. |
|
| `Installing 2.16.0… the app restarts when it is done` | The session was committed; the result follows as its own toast. |
|
||||||
| `Kurulum başarısız: INSTALL_FAILED_…` | From `OtaInstallResultReceiver` — the package manager's own words for why it refused. |
|
| `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`. |
|
||||||
| `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. |
|
| `… 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
|
## Why a PackageInstaller session, not `pm install`
|
||||||
that fails every time, and the reason is worth writing down because the symptom pointed
|
|
||||||
somewhere else entirely.
|
|
||||||
|
|
||||||
The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
|
Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private
|
||||||
— it passes the path to the package manager service, which opens it from *its* process and
|
cache. That fails on this head unit: `pm` hands the path to the package manager service,
|
||||||
SELinux context, where an app's private cache is not readable. The install failed, and
|
which opens it from its own process and SELinux context, where an app's private cache is not
|
||||||
because `install()` returned a bare boolean, the caller reported the only failure it knew
|
readable — and the failure surfaced as a signature error it never was. A session takes no
|
||||||
how to name: `Kurulum reddedildi (imza uyuşmuyor?)`. A correctly signed build, refused with
|
path: the app opens the archive itself and streams it in. The approach comes from
|
||||||
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
|
|
||||||
[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
|
[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:
|
**Not yet tried on this car:** whether the session installs silently as the system user, or
|
||||||
|
asks for a confirmation.
|
||||||
- **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.
|
|
||||||
|
|||||||
@@ -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)
|
No OBD dongle and no phone in the loop: the app runs on the head unit and reads the car's own
|
||||||
[](LICENSE)
|
APIs.
|
||||||
|
|
||||||
Sends live telemetry from an **MG4 (SAIC eh32)** to
|
- Repository: https://git.stinnesbeck.com/nils/mg4
|
||||||
[A Better Route Planner](https://abetterrouteplanner.com) — battery level, speed, range,
|
- Fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), itself built on malys' EVABRPUploader and Leon Kernan's `ABRP_Uploader`.
|
||||||
temperature, charging state and position — straight from the car's own APIs.
|
- What changed and what is still to do: [`SPECS.md`](SPECS.md).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
> ⚠️ **No warranty, no liability.** This software is provided "as is" and runs on a
|
> ⚠️ **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.
|
> [`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
|
> MG and MG4 are third-party marks used only to identify compatibility.
|
||||||
> or approval is claimed.
|
|
||||||
|
|
||||||
> **Fork notice.** This is a fork of Leon Kernan's `ABRP_Uploader`, substantially
|
> **Licence.** MIT, like Leon Kernan's original `ABRP_Uploader` and the forks in between —
|
||||||
> reworked. **Read [`LICENSE.md`](LICENSE.md) before redistributing anything** — the
|
> except the bundled `EVHardware/` library, which is PolyForm Noncommercial, so the built app
|
||||||
> upstream project carries no licence, which limits what may legally be published.
|
> may only be used and shared non-commercially. See [`LICENSE.md`](LICENSE.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshot](#screenshot)
|
||||||
- [Overview](#overview)
|
- [How it fits together](#how-it-fits-together)
|
||||||
- [Install](#install)
|
- [The app](#the-app)
|
||||||
- [Configuration](#configuration)
|
- [Install on the car](#install-on-the-car)
|
||||||
|
- [Setup](#setup)
|
||||||
- [Building](#building)
|
- [Building](#building)
|
||||||
|
- [Server and dashboards](#server-and-dashboards)
|
||||||
- [Project layout](#project-layout)
|
- [Project layout](#project-layout)
|
||||||
- [Project documents](#project-documents)
|
- [Project documents](#project-documents)
|
||||||
- [Security](#security)
|
|
||||||
- [Contributing](#contributing)
|
|
||||||
- [Legal](#legal)
|
|
||||||
- [Credits](#credits)
|
- [Credits](#credits)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshot
|
||||||
|
|
||||||
Version 2.2.6 introduces an OLED-black interface with charcoal cards, muted gold and
|
Version 2.8.2 at the head unit's 1920x720 pixels and 160 dpi, in full screen (Android 9 emulator; no
|
||||||
cyan accents, a vehicle overview, and responsive side or bottom navigation. This
|
vehicle there, so every value shows as a dash).
|
||||||
app-specific theme intentionally overrides the shared EVSuite visual tokens.
|
|
||||||
Tablet UI previews at 1280×720; vehicle readings are unavailable on this device.
|
|
||||||
|
|
||||||
<p align="center">
|
<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/nilsmg4-2.8.2-overview.png" width="900" alt="Overview tab: battery, range, range estimates, power and speed tiles above the energy and tyre-pressure cards">
|
||||||
<img src="screenshots/evabrp-2.2.6-abrp.png" width="760" alt="Dark ABRP account configuration">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
## How it fits together
|
||||||
|
|
||||||
## Overview
|
```
|
||||||
| Signal | Source | Firmwares |
|
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
||||||
|---|---|---|
|
│ ──daily chunks─────▶ │
|
||||||
| State of charge | `EVHardware` vendor charging service, with a SWI68 property fallback | all supported (fallback SWI68 only) |
|
│ ──charging sessions▶ ▼
|
||||||
| Range | `EVHardware` vendor charging service, standard AAOS property, then SWI68 fallback | all supported |
|
│ InfluxDB 2 ◀── Grafana
|
||||||
| Speed, parked state | `EVHardware` shared energy snapshot | all supported |
|
└──live telemetry─────▶ ABRP
|
||||||
| 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 | — |
|
|
||||||
|
|
||||||
All vehicle telemetry goes through **EVHardware**, which detects the generation
|
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
||||||
(SWI68/69/131/132/133/165), owns property ids and units, and applies the shared nullable
|
ABRP and the server do not depend on each other. The server only stores. The car authenticates
|
||||||
fallback order. `CarPropertyAdapter` remains only as transport for the debug-only VHAL probe
|
with the server using a token of its own, obtained by pairing with a phone (below).
|
||||||
and is never instantiated by the production service. See [`FIRMWARE.md`](FIRMWARE.md).
|
|
||||||
|
|
||||||
Odometer and tyre pressures sit behind privileged AAOS permissions: a platform-signed
|
## The app
|
||||||
install sends them, any other install simply omits them.
|
|
||||||
|
|
||||||
A property the car will not give up is **omitted** from the payload, never sent as zero —
|
| Tab | What it shows |
|
||||||
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
|
| **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 |
|
||||||
interval)` is dropped rather than resent, so a stale fix cannot report the car back at the
|
| **Consumption** | Daily and period consumption, efficiency |
|
||||||
place it set off from.
|
| **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
|
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
|
||||||
is no window service, door listener or window command path in the app.
|
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
|
Signals the MG4 does not publish (battery, cabin and outside temperature, pack energy and
|
||||||
The MG4 head unit hides Settings and APK install. The known route in:
|
capacity) are listed in [`SPECS.md`](SPECS.md#signals-the-car-does-not-provide).
|
||||||
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:
|
|
||||||
|
|
||||||
1. Open any app with a text field and tap it to raise the on-screen keyboard — e.g. the
|
## Install on the car
|
||||||
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.
|
|
||||||
|
|
||||||
> ⚠️ You are enabling developer options and sideloading on a car. Do this **parked**, and
|
The car build (`NilsMG4-unstable-<version>.apk`) runs with the system UID and is signed with the
|
||||||
> only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md).
|
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 |
|
1. Copy the APK to a USB stick and plug it in.
|
||||||
|---|---|---|
|
2. Open **Bluetooth Settings** and select the car name.
|
||||||
| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it |
|
3. Long-press the **comma** key until the keyboard settings appear.
|
||||||
| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land |
|
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
|
With adb:
|
||||||
are marked pre-release.
|
|
||||||
|
|
||||||
### 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.
|
After that first install, the app updates itself: publish a build with `publish-car`, then
|
||||||
2. Long-press the **comma** key until the keyboard settings appear.
|
press the refresh button in the app's top bar (`OTA.md`). The car fetches it through your car
|
||||||
3. **Android Keyboard Settings → Languages**.
|
server, so the repository stays private.
|
||||||
4. Search for `file` in the search box at the top.
|
|
||||||
5. That gives you the Files app.
|
|
||||||
|
|
||||||
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.
|
### On your phone
|
||||||
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.
|
|
||||||
|
|
||||||
Typing a long API key and token on the car's on-screen keyboard is painful. Instead you can
|
Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a
|
||||||
put them in a text file and tap **Import file** — see [Config file](#config-file) below.
|
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
|
||||||
## Configuration
|
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
|
||||||
| Setting | Default | Notes |
|
ten minutes), never stored or logged.
|
||||||
|---|---|---|
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
|
|
||||||
To avoid typing on the head unit, put your settings in a plain text file and tap **Import file**
|
Instead of typing, put the settings in a text file and tap **Import file** on the Server tab.
|
||||||
on the **ABRP** tab. The app fills in whatever the file contains. Nothing is sent — press
|
The head unit has no file picker, so copy the file to
|
||||||
**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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
on the USB stick or the internal storage. The app scans that folder on every mounted volume and
|
||||||
scans that folder on every mounted volume and takes the first file that parses — the name does
|
takes the first file that parses. Format, one `key = value` per line, `#` for comments; keys left
|
||||||
not matter. If nothing is found it shows the exact path to copy to. On a phone or tablet, where
|
out keep their current value:
|
||||||
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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# ABRP Uploader config
|
server_url = https://car.k8s.stinnesbeck.com
|
||||||
api_key = your-abrp-api-key
|
token = the server's INGEST_TOKEN (not needed after pairing)
|
||||||
token = your-abrp-user-token
|
installation_id = only to carry over history from an install older than 2.9.0
|
||||||
|
interval_sec = 60 # 15 / 30 / 60 / 120 / 300
|
||||||
# optional — the cadence controls, same values as the UI
|
|
||||||
interval_sec = 60 # snapped to 15 / 30 / 60 / 120 / 300
|
|
||||||
boost_low_soc = true
|
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 —
|
After a successful import the app deletes the file, since it holds the token; credentials live
|
||||||
your credentials live only in the app's encrypted store afterwards.
|
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
|
### Power
|
||||||
|
|
||||||
The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping
|
The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping head
|
||||||
head unit; it costs nothing while the car is off. While the car is on, both the upload tick
|
unit and costs nothing while the car is off.
|
||||||
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**.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
Requires JDK 17 and the Android SDK. With [mise](https://mise.jdev):
|
|
||||||
|
|
||||||
```bash
|
With Nix:
|
||||||
mise install # JDK
|
|
||||||
mise run bootstrap # Android SDK + local.properties
|
```sh
|
||||||
mise run check # permission gate + lint + tests
|
nix develop # JDK 21, Android SDK, Go
|
||||||
mise run build # debug APK
|
./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
|
`build-car` writes `build/car/NilsMG4-unstable-<version>.apk` and a `.sha256` next to it, and
|
||||||
`./gradlew assembleStableDebug`.
|
checks the signing certificate before it finishes.
|
||||||
|
|
||||||
Release builds are signed from environment variables. These sign the **APK** and have
|
Tests and lint:
|
||||||
nothing to do with your ABRP API key, which you type into the app itself:
|
|
||||||
|
|
||||||
| Variable | What it is |
|
```sh
|
||||||
|---|---|
|
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
|
||||||
| `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 |
|
|
||||||
|
|
||||||
**Never commit a keystore or a password.** Put them in `mise.local.toml`, which is
|
[`mise`](https://mise.jdx.dev) tasks from upstream (`mise run check`, `mise run build`) still
|
||||||
gitignored, or in GitHub Actions secrets.
|
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
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
app/src/main upload service, ABRP payload and UI
|
app/src/main app: upload service, server client, pairing, UI
|
||||||
EVHardware shared vehicle reads, energy snapshot and integration
|
app/src/stable no-op update hook (no updater in the APK)
|
||||||
app/src/stable no-op update hook — the stable channel cannot self-update
|
app/src/unstable car build: system UID, updater (currently pointed nowhere)
|
||||||
app/src/unstable audited updater policy; runtime trigger currently suspended
|
app/src/debug VHAL probe tools, absent from release builds
|
||||||
app/src/debug VHAL probe tools, absent from every release build
|
|
||||||
app/src/test JVM unit tests
|
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
|
## Project documents
|
||||||
|
|
||||||
| Document | What it covers |
|
| 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 |
|
| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository |
|
||||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change |
|
| [DESIGN.md](DESIGN.md) | The EVSuite design system (the app overrides its palette) |
|
||||||
| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure |
|
| [SECURITY.md](SECURITY.md) | Threat model |
|
||||||
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing |
|
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer; read before installing |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | Release history |
|
| [CHANGELOG.md](CHANGELOG.md) | Upstream release history |
|
||||||
| [LICENSE.md](LICENSE.md) | Licence text |
|
| [LICENSE.md](LICENSE.md) | Licences: MIT, and PolyForm Noncommercial for `EVHardware/` |
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Credits
|
## 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).
|
- 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.
|
// APK signing — nothing to do with the user's ABRP credentials. These values are ONLY for
|
||||||
// Ces valeurs servent UNIQUEMENT au build : elles prouvent que l'APK vient bien de
|
// the build: they prove the APK comes from us and never enter the app. The ABRP API key and
|
||||||
// nous et n'entrent jamais dans l'application. La clé d'API et le token ABRP, eux,
|
// token are entered in the app and stored encrypted on the device (see SecurePrefs).
|
||||||
// sont saisis dans l'app et stockés chiffrés sur l'appareil (voir 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,
|
// A keystore holds several keys: SIGNING_STORE_PASSWORD opens the container,
|
||||||
// SIGNING_KEY_ALIAS choisit la clé, SIGNING_KEY_PASSWORD ouvre cette clé-là.
|
// SIGNING_KEY_ALIAS picks the key, SIGNING_KEY_PASSWORD opens that key.
|
||||||
// Lues depuis l'environnement (CI) ou gradle.properties local — JAMAIS commitées.
|
// Read from the environment (CI) or a local gradle.properties — NEVER committed.
|
||||||
def keystorePath = System.getenv("SIGNING_KEYSTORE") ?: project.findProperty("signing.keystore")
|
def keystorePath = System.getenv("SIGNING_KEYSTORE") ?: project.findProperty("signing.keystore")
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePath != null && file(keystorePath).exists()) {
|
if (keystorePath != null && file(keystorePath).exists()) {
|
||||||
@@ -31,33 +31,27 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.evsuite.abrp"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 56
|
versionCode 95
|
||||||
versionName "2.2.39"
|
versionName "2.18.0"
|
||||||
|
|
||||||
// MongoDB credentials never ship in the APK. The app only talks to the small
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// authenticated API in server/, which owns the database connection.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
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\""
|
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
// null quand aucun keystore n'est configuré : le build reste possible sur une
|
// null when no keystore is configured: the build still works on a machine without
|
||||||
// machine sans secrets (APK non signé), au lieu d'échouer.
|
// secrets (unsigned APK) instead of failing.
|
||||||
def platformSigning = signingConfigs.findByName("platform")
|
def platformSigning = signingConfigs.findByName("platform")
|
||||||
|
|
||||||
// Deux canaux de distribution :
|
// Two distribution channels:
|
||||||
// - stable : diffusé en release taguée, AUCUN auto-update. L'utilisateur installe
|
// - stable: no self-update at all. The user installs it; nothing in the app fetches an
|
||||||
// lui-même ; rien dans l'app ne va chercher un APK.
|
// APK (the updater is not even in this APK).
|
||||||
// - unstable : pré-releases publiées à chaque push sur master, avec OTA pour que les testeurs
|
// - unstable: the car build (system UID, platform-signed), with the OTA updater, so the
|
||||||
// soient à jour sans manipulation. Installable à côté du stable.
|
// car can update itself from published pre-releases. Installs next to stable.
|
||||||
flavorDimensions += "channel"
|
flavorDimensions += "channel"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
stable {
|
stable {
|
||||||
@@ -67,16 +61,13 @@ android {
|
|||||||
unstable {
|
unstable {
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
applicationIdSuffix ".unstable"
|
applicationIdSuffix ".unstable"
|
||||||
// Android uses versionCode, not versionName, to order package updates. Give
|
// Three-part versions, the same as defaultConfig ("2.8.1-unstable"). Upstream added
|
||||||
// every rolling CI build a strictly increasing code so Package Installer does
|
// a fourth part for its CI build number; there is no CI here and the OTA updater is
|
||||||
// not treat a newer unstable APK as the same package version.
|
// off. The version code keeps upstream's x100000 so it never drops below codes
|
||||||
versionCode defaultConfig.versionCode * 100000 +
|
// already installed on the car (2.8.0.0 was 6600000): a lower code is a downgrade,
|
||||||
(project.findProperty('unstableBuild') ?: '0').toString().toInteger()
|
// which the installer refuses.
|
||||||
// Le numéro de build fait partie du COEUR numérique de la version
|
versionCode defaultConfig.versionCode * 100000
|
||||||
// ("1.0.42-unstable"), pas du suffixe : l'updater compare des segments
|
versionName defaultConfig.versionName
|
||||||
// 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'}"
|
|
||||||
versionNameSuffix "-unstable"
|
versionNameSuffix "-unstable"
|
||||||
buildConfigField "boolean", "OTA_ENABLED", "true"
|
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
|
// The version build-car puts in the APK's file name ("NilsMG4-unstable-2.8.1.apk"), which
|
||||||
// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la
|
// is also the name the OTA updater reads a release's version from.
|
||||||
// pré-release est la constante "unstable", écrasée à chaque build.
|
|
||||||
tasks.register('printUnstableVersion') {
|
tasks.register('printUnstableVersion') {
|
||||||
doLast {
|
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 {
|
androidComponents {
|
||||||
onVariants(selector().all()) { variant ->
|
onVariants(selector().all()) { variant ->
|
||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
if (variant.buildType == "release") {
|
if (variant.buildType == "release") {
|
||||||
// Le canal unstable garde un nom de fichier stable : l'updater identifie
|
// A fixed name for the unstable release output. What is published for the car
|
||||||
// la version par le tag de la pré-release, jamais par le nom d'APK.
|
// 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"
|
output.outputFileName.set(variant.flavorName == "unstable"
|
||||||
? "FatihsMG4-unstable.apk"
|
? "NilsMG4-unstable.apk"
|
||||||
: "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.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
|
<!-- 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. -->
|
only status UI the service has. Without it the notification is silently dropped. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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 -->
|
<!-- Android Automotive / CarPropertyManager permissions -->
|
||||||
<uses-permission android:name="android.car.permission.CAR_SPEED" />
|
<uses-permission android:name="android.car.permission.CAR_SPEED" />
|
||||||
@@ -52,7 +51,8 @@
|
|||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".AbrpUploadService"
|
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
|
<!-- 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
|
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,
|
* 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
|
* 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
|
* credentials or a toggle. The only thing it needs is a network, and a missing one is
|
||||||
* missing one is handled by caching chunks locally until the connection returns.
|
* handled by queueing locally until the connection returns.
|
||||||
*/
|
*/
|
||||||
public class AbrpUploadService extends Service {
|
public class AbrpUploadService extends Service {
|
||||||
|
|
||||||
@@ -64,7 +66,8 @@ public class AbrpUploadService extends Service {
|
|||||||
private ConsumptionTracker consumptionTracker;
|
private ConsumptionTracker consumptionTracker;
|
||||||
private ConsumptionCloudClient consumptionCloudClient;
|
private ConsumptionCloudClient consumptionCloudClient;
|
||||||
private ChargeSessionTracker chargeSessionTracker;
|
private ChargeSessionTracker chargeSessionTracker;
|
||||||
private WifiAutoConnect wifi;
|
private TelemetryUploader telemetryUploader;
|
||||||
|
private SteeringKeyListener steeringKeys;
|
||||||
private ScheduledExecutorService scheduler;
|
private ScheduledExecutorService scheduler;
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
|
|
||||||
@@ -93,14 +96,16 @@ public class AbrpUploadService extends Service {
|
|||||||
consumptionTracker = ConsumptionTracker.get(this);
|
consumptionTracker = ConsumptionTracker.get(this);
|
||||||
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
||||||
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
||||||
wifi = new WifiAutoConnect(getApplicationContext());
|
telemetryUploader = new TelemetryUploader(getApplicationContext());
|
||||||
|
steeringKeys = new SteeringKeyListener(this, prefs);
|
||||||
|
steeringKeys.register();
|
||||||
|
|
||||||
scheduler.scheduleWithFixedDelay(
|
scheduler.scheduleWithFixedDelay(
|
||||||
this::safeUploadCycle,
|
this::safeUploadCycle,
|
||||||
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||||
|
|
||||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
|
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");
|
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
|
* Enters the foreground. Foreground service types do not exist before API 29, so on this
|
||||||
* service types do not exist before API 29, so on this API-28 head unit a plain
|
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
|
||||||
* startForeground is the only correct call.
|
* location type is what lets the GPS subscription run in the background.
|
||||||
*/
|
*/
|
||||||
private void startInForeground() {
|
private void startInForeground() {
|
||||||
Notification notification = buildNotification(lastStatus);
|
Notification notification = buildNotification(lastStatus);
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
ServiceCompat.startForeground(
|
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 {
|
} else {
|
||||||
startForeground(NOTIF_ID, notification);
|
startForeground(NOTIF_ID, notification);
|
||||||
}
|
}
|
||||||
@@ -133,6 +139,11 @@ public class AbrpUploadService extends Service {
|
|||||||
running = false;
|
running = false;
|
||||||
prefs.edit().putBoolean("service_running", false).apply();
|
prefs.edit().putBoolean("service_running", false).apply();
|
||||||
if (scheduler != null) scheduler.shutdownNow();
|
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");
|
Log.i(TAG, "Service stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,17 +160,27 @@ public class AbrpUploadService extends Service {
|
|||||||
private void doUpload() {
|
private void doUpload() {
|
||||||
long sampleMs = System.currentTimeMillis();
|
long sampleMs = System.currentTimeMillis();
|
||||||
EnergySnapshot vehicle = energyReader.read(sampleMs);
|
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);
|
consumptionTracker.sample(vehicle);
|
||||||
chargeSessionTracker.sample(vehicle);
|
chargeSessionTracker.sample(vehicle);
|
||||||
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
||||||
|
|
||||||
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
|
UploadSettings settings = UploadSettings.from(prefs);
|
||||||
// exactly when being offline costs something. Rate-limited internally.
|
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
|
||||||
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
// nowhere to send it.
|
||||||
wifi.ensureConnected(false);
|
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 ----------
|
// ---------- 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 Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1);
|
||||||
private final List<float[]> points = new ArrayList<>();
|
private final List<float[]> points = new ArrayList<>();
|
||||||
private final float[] socPower = new float[101];
|
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) {
|
synchronized void add(Float soc, Float power) {
|
||||||
if (soc == null || power == null || power >= 0) return;
|
if (soc == null || power == null || power >= 0) return;
|
||||||
float p = Math.abs(power);
|
float p = Math.abs(power);
|
||||||
@@ -33,13 +33,13 @@ public final class ChargingGraphView extends View {
|
|||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
synchronized String tableText() {
|
synchronized String tableText() {
|
||||||
StringBuilder out = new StringBuilder("SOC Şarj gücü\n");
|
StringBuilder out = new StringBuilder("SOC Charge power\n");
|
||||||
boolean found = false;
|
boolean found = false;
|
||||||
for (int soc=0; soc<=100; soc++) if (!Float.isNaN(socPower[soc])) {
|
for (int soc=0; soc<=100; soc++) if (!Float.isNaN(socPower[soc])) {
|
||||||
found = true;
|
found = true;
|
||||||
out.append(String.format(Locale.getDefault(), "%3d %% %6.1f kW\n", soc, socPower[soc]));
|
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) {
|
@Override protected synchronized void onDraw(Canvas c) {
|
||||||
super.onDraw(c); float left=58, right=getWidth()-12, top=12, bottom=getHeight()-38;
|
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.JSONArray;
|
||||||
import org.json.JSONObject;
|
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.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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
|
* Server URL, token and installation id come from {@link ServerConfig}, read on every use so
|
||||||
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
|
* a change in the Server tab applies at once. The installation id is kept across updates, and
|
||||||
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
|
* a reinstall derives the same one from the head unit ({@link ServerConfig#installationIdFor})
|
||||||
* picks up right where the old one left off instead of showing zeroes.
|
* 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 {
|
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 CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L;
|
||||||
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 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 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 PREFS = "consumption_cloud";
|
||||||
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
|
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
|
||||||
private static final int MAX_CHARGES_PER_SYNC = 10;
|
private static final int MAX_CHARGES_PER_SYNC = 10;
|
||||||
|
|
||||||
private final String endpoint;
|
private final Context context;
|
||||||
private final String token;
|
|
||||||
private final String installationId;
|
|
||||||
private final SharedPreferences prefs;
|
private final SharedPreferences prefs;
|
||||||
private long lastConsumptionAttemptMs;
|
private long lastConsumptionAttemptMs;
|
||||||
private long lastChargeAttemptMs;
|
private long lastChargeAttemptMs;
|
||||||
@@ -65,21 +57,19 @@ final class ConsumptionCloudClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ConsumptionCloudClient(Context context) {
|
ConsumptionCloudClient(Context context) {
|
||||||
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
|
this.context = context.getApplicationContext();
|
||||||
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
|
|
||||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
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")
|
private ServerConfig config() {
|
||||||
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
|
return ServerConfig.get(context);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
|
* 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
|
* 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
|
* 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
|
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
|
||||||
* monotonic clock instead, so a backwards NTP correction cannot stall
|
* 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) {
|
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
|
||||||
long elapsed = android.os.SystemClock.elapsedRealtime();
|
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||||
if (endpoint.isEmpty() || token.isEmpty()) {
|
if (!config().isConfigured()) {
|
||||||
Log.i(TAG, "Sync skipped: API not configured");
|
Log.i(TAG, "Sync skipped: API not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,7 +86,7 @@ final class ConsumptionCloudClient {
|
|||||||
// Drain any cached chunks first — a backlog from an offline period has priority.
|
// Drain any cached chunks first — a backlog from an offline period has priority.
|
||||||
int cached = drainChunkCache();
|
int cached = drainChunkCache();
|
||||||
if (cached > 0) {
|
if (cached > 0) {
|
||||||
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
|
logEvent(true, 200, "Cache drained: " + cached + " chunk(s) sent");
|
||||||
invalidateHistory();
|
invalidateHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,14 +105,14 @@ final class ConsumptionCloudClient {
|
|||||||
try {
|
try {
|
||||||
JSONObject body = consumptionBody(chunk, nowMs);
|
JSONObject body = consumptionBody(chunk, nowMs);
|
||||||
if (send("/v1/consumption/daily", "PUT", body)) {
|
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("%.1f", chunk.day.km) + "km, +"
|
||||||
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
|
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
|
||||||
invalidateHistory();
|
invalidateHistory();
|
||||||
} else {
|
} else {
|
||||||
cacheChunk(body);
|
cacheChunk(body);
|
||||||
int pending = pendingChunkCount();
|
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) {
|
} catch (Exception e) {
|
||||||
// Network failure: cache the chunk for the next cycle.
|
// Network failure: cache the chunk for the next cycle.
|
||||||
@@ -131,7 +121,7 @@ final class ConsumptionCloudClient {
|
|||||||
cacheChunk(body);
|
cacheChunk(body);
|
||||||
} catch (Exception ignored) { }
|
} catch (Exception ignored) { }
|
||||||
int pending = pendingChunkCount();
|
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());
|
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +180,7 @@ final class ConsumptionCloudClient {
|
|||||||
return loadChunkCache().length();
|
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) {
|
private void logEvent(boolean success, int httpStatus, String detail) {
|
||||||
UploadLog log = AbrpUploadService.log();
|
UploadLog log = AbrpUploadService.log();
|
||||||
if (log == null) return;
|
if (log == null) return;
|
||||||
@@ -200,7 +190,7 @@ final class ConsumptionCloudClient {
|
|||||||
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
|
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSONObject body = new JSONObject()
|
JSONObject body = new JSONObject()
|
||||||
.put("installationId", installationId)
|
.put("installationId", config().installationId)
|
||||||
.put("recordedAt", nowMs)
|
.put("recordedAt", nowMs)
|
||||||
.put("date", snapshot.date)
|
.put("date", snapshot.date)
|
||||||
.put("day", totals(snapshot.day));
|
.put("day", totals(snapshot.day));
|
||||||
@@ -220,7 +210,7 @@ final class ConsumptionCloudClient {
|
|||||||
try {
|
try {
|
||||||
syncPendingCharges(charges, pending);
|
syncPendingCharges(charges, pending);
|
||||||
} catch (Exception e) {
|
} 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());
|
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");
|
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
for (JSONObject session : pending) {
|
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");
|
String id = session.getString("sessionId");
|
||||||
Log.i(TAG, "Uploading charge session: " + id);
|
Log.i(TAG, "Uploading charge session: " + id);
|
||||||
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
|
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
|
||||||
charges.markUploaded(id);
|
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);
|
Log.i(TAG, "Successfully uploaded charge session: " + id);
|
||||||
} else {
|
} 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);
|
Log.w(TAG, "Failed to upload charge session: " + id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,36 +237,20 @@ final class ConsumptionCloudClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean send(String path, String method, JSONObject body) throws Exception {
|
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 {
|
try {
|
||||||
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
ServerHttp.Response response = ServerHttp.request(method, url, config().token, body);
|
||||||
String url = trimSlash(endpoint) + path;
|
Log.i(TAG, "Response: HTTP " + response.status);
|
||||||
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
|
if (!response.ok()) {
|
||||||
Log.i(TAG, "Body: " + body.toString());
|
String err = response.excerpt();
|
||||||
connection = (HttpURLConnection) new URL(url).openConnection();
|
Log.w(TAG, "Sync rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
|
||||||
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));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
InputStream response = connection.getInputStream();
|
|
||||||
if (response != null) try (InputStream ignored = response) { }
|
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Network error: " + e.getMessage());
|
Log.w(TAG, "Network error: " + e.getMessage());
|
||||||
throw e;
|
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.
|
* Returns the current cache immediately; the fresh copy lands on the next poll.
|
||||||
*/
|
*/
|
||||||
synchronized List<DayRecord> fetchHistoryIfDue() {
|
synchronized List<DayRecord> fetchHistoryIfDue() {
|
||||||
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
|
if (!config().isConfigured()) return cachedHistory;
|
||||||
long elapsed = android.os.SystemClock.elapsedRealtime();
|
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||||
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
|
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
|
||||||
return cachedHistory;
|
return cachedHistory;
|
||||||
@@ -310,11 +284,11 @@ final class ConsumptionCloudClient {
|
|||||||
List<DayRecord> fresh = fetchHistory();
|
List<DayRecord> fresh = fetchHistory();
|
||||||
if (!fresh.isEmpty()) cachedHistory = fresh;
|
if (!fresh.isEmpty()) cachedHistory = fresh;
|
||||||
Log.i(TAG, "Fetched " + fresh.size() + " history records from cloud");
|
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) {
|
} catch (Exception e) {
|
||||||
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||||
Log.w(TAG, "History fetch unavailable: " + msg);
|
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
|
* 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.
|
* status for the log.
|
||||||
*/
|
*/
|
||||||
String flushNow(ConsumptionTracker tracker, long nowMs) {
|
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();
|
int cached = drainChunkCache();
|
||||||
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
|
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
|
||||||
int total = cached;
|
int total = cached;
|
||||||
@@ -348,50 +322,39 @@ final class ConsumptionCloudClient {
|
|||||||
}
|
}
|
||||||
int pending = pendingChunkCount();
|
int pending = pendingChunkCount();
|
||||||
String msg = total > 0
|
String msg = total > 0
|
||||||
? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "")
|
? total + " chunk(s) uploaded" + (pending > 0 ? ", " + pending + " still pending" : "")
|
||||||
: (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok");
|
: (pending > 0 ? pending + " chunk(s) could not be uploaded (internet?)" : "No new data to upload");
|
||||||
logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg);
|
logEvent(total > 0, total > 0 ? 200 : 0, "Manual sync: " + msg);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DayRecord> fetchHistory() throws Exception {
|
private List<DayRecord> fetchHistory() throws Exception {
|
||||||
HttpURLConnection connection = null;
|
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
|
||||||
try {
|
ServerHttp.Response response = ServerHttp.request("GET", url, config().token, null);
|
||||||
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
|
Log.i(TAG, "History GET response: HTTP " + response.status);
|
||||||
connection = (HttpURLConnection) new URL(url).openConnection();
|
if (!response.ok()) {
|
||||||
connection.setRequestMethod("GET");
|
String err = response.excerpt();
|
||||||
connection.setConnectTimeout(TIMEOUT_MS);
|
Log.w(TAG, "History fetch rejected with HTTP " + response.status + (err.isEmpty() ? "" : ": " + err));
|
||||||
connection.setReadTimeout(TIMEOUT_MS);
|
logEvent(false, response.status, "History GET HTTP " + response.status + (err.isEmpty() ? "" : " — " + err));
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + token);
|
return cachedHistory;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
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) {
|
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
|
||||||
@@ -400,35 +363,6 @@ final class ConsumptionCloudClient {
|
|||||||
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
|
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) {
|
private static String trimSlash(String value) {
|
||||||
int end = value.length();
|
int end = value.length();
|
||||||
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ final class ConsumptionTracker {
|
|||||||
if (cloudLifetime.km > local.km) {
|
if (cloudLifetime.km > local.km) {
|
||||||
lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc);
|
lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc);
|
||||||
persist();
|
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)");
|
+ 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.util.AttributeSet;
|
||||||
import android.view.View;
|
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 {
|
public final class PremiumBackdropView extends View {
|
||||||
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Paint gold = 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) {
|
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||||
float radius = Math.max(1, Math.max(w, h) * 0.65f);
|
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,
|
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,
|
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) {
|
@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;
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
|
* User-configurable upload policy.
|
||||||
* 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.
|
* 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 {
|
final class UploadSettings {
|
||||||
|
|
||||||
static final String KEY_AUTOSTART = "autostart";
|
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
|
||||||
static final boolean DEFAULT_AUTOSTART = true;
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:state_enabled="false" android:color="@color/ev_outline" />
|
<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" />
|
<item android:color="@color/ev_text_secondary" />
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:state_enabled="false" android:color="@color/ev_surface_raised" />
|
<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" />
|
<item android:color="@color/ev_outline" />
|
||||||
</selector>
|
</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>
|
||||||
<item android:gravity="bottom" android:height="2dp">
|
<item android:gravity="bottom" android:height="2dp">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="@color/ev_accent" />
|
<solid android:color="?attr/evAccent" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
<solid android:color="@color/ev_cyan_deep" />
|
<solid android:color="?attr/evAccentDeep" />
|
||||||
<stroke android:width="1dp" android:color="@color/ev_cyan" />
|
<stroke android:width="1dp" android:color="?attr/evAccentLight" />
|
||||||
<corners android:radius="14dp" />
|
<corners android:radius="14dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
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"
|
<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>
|
</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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="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">
|
<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="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="#102D30" android:strokeColor="#527B7C" android:strokeWidth="1.3"/>
|
<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="#D8C39A" android:strokeWidth="5" android:strokeLineCap="round"/>
|
<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>
|
</vector>
|
||||||
|
|||||||
@@ -4,25 +4,23 @@
|
|||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal">
|
<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">
|
<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">
|
<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" />
|
<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/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/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/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_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_live" />
|
||||||
<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/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" />
|
<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>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
|
<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">
|
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
layouts). Version, update check and About are on the Settings tab. -->
|
||||||
<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" />
|
<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: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" />
|
<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" />
|
||||||
</LinearLayout>
|
<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" />
|
||||||
<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" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" />
|
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,24 +1,41 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||||
<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" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
<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" />
|
<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" />
|
<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" />
|
</LinearLayout>
|
||||||
<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="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>
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="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_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<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_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
<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/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||||
<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.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.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.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 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.TextInputLayout>
|
||||||
<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" />
|
<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.textfield.TextInputLayout>
|
<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>
|
||||||
</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"?>
|
<?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">
|
<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="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">
|
<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" />
|
<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>
|
||||||
<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">
|
<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" />
|
<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">
|
<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: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" />
|
<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: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>
|
</LinearLayout>
|
||||||
<ImageView android:layout_width="92dp" android:layout_height="180dp" android:src="@drawable/ic_vehicle_outline" android:contentDescription="@string/design_car_desc" />
|
<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">
|
<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: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" />
|
<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: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>
|
||||||
</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" />
|
<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>
|
||||||
</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>
|
</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"/>
|
<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="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">
|
<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: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: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"/>
|
<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>
|
||||||
<LinearLayout android:layout_width="240dp" android:layout_height="wrap_content" android:orientation="vertical">
|
<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"/>
|
<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>
|
||||||
</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"/>
|
<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">
|
<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"/>
|
<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="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">
|
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
layouts). Version, update check and About are on the Settings tab. -->
|
||||||
<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"/>
|
<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: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"/>
|
<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" />
|
||||||
</LinearLayout>
|
<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" />
|
||||||
<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"/>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"/>
|
<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">
|
<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/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/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/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_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_live"/>
|
||||||
<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/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"/>
|
<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>
|
||||||
</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">
|
<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">
|
<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/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: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: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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"?>
|
<?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="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="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">
|
<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="@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="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="@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="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="@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="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="@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="Ş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="@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="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="@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="Ş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="@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="Ş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="@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="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_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>
|
</GridLayout>
|
||||||
</LinearLayout>
|
</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">
|
<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"/>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/charging_curve_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="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"/>
|
|
||||||
<com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="280dp" android:layout_marginTop="12dp"/>
|
<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>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</com.evsuite.abrp.StableScrollView>
|
||||||
|
|||||||
@@ -1,43 +1,35 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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="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="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">
|
<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"/>
|
<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="Yenile" 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/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>
|
</LinearLayout>
|
||||||
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none">
|
<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">
|
<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_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="Son 7 gün"/>
|
<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="Son 30 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="@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="Trip A"/>
|
<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="Trip B"/>
|
<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>
|
</LinearLayout>
|
||||||
</HorizontalScrollView>
|
</HorizontalScrollView>
|
||||||
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2">
|
<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="@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="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="@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="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="@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="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="@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="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="@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="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_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>
|
</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>
|
</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">
|
<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"/>
|
<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.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.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"/>
|
||||||
<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"/>
|
<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"/>
|
||||||
</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"/>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</com.evsuite.abrp.StableScrollView>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-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">
|
<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" />
|
<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" />
|
<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" />
|
<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>
|
</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"?>
|
<?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">
|
||||||
<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">
|
||||||
<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: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" />
|
<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/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" />
|
<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" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<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" android:layout_marginTop="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_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/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" />
|
<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" />
|
<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.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.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>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</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"?>
|
<?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">
|
<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">
|
<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" />
|
<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">
|
<TableLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:stretchColumns="0" android:shrinkColumns="0">
|
||||||
@@ -16,44 +71,24 @@
|
|||||||
</LinearLayout>
|
</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">
|
<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" />
|
<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">
|
<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: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" />
|
<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: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>
|
</LinearLayout>
|
||||||
<ImageView android:layout_width="92dp" android:layout_height="180dp" android:src="@drawable/ic_vehicle_outline" android:contentDescription="@string/design_car_desc" />
|
<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">
|
<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: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" />
|
<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: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>
|
||||||
</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" />
|
<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" />
|
|
||||||
<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>
|
</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