From 6145736c9766f8ef4d269fa113421bca1333c43e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 14:23:04 +0200 Subject: [PATCH] App 2.3.0: Server tab, live telemetry to the car server - New Server tab: server URL, ingest token and installation id, with Save, Test connection (health, then a read-only token check) and the config-file import that upstream removed (app folder / USB stick first, document picker as fallback). Settings are kept encrypted and read on every use, so the service picks up a change without a restart. Each installation generates its own id; the inherited upstream UUID and the baked-in CONSUMPTION_API_* build fields are gone. - Live telemetry restored from before ea8032a (TelemetryPayload, UploadCadence, UploadSettings, GPS) and sent in batches to POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds ext_temp, voltage and current. Unsent samples queue in memory (about 8 h) while offline. - Upload tab: the live-telemetry switch and cadence controls work again; the dead service and autostart switches are removed. - Manifest: location permissions and foreground service types. - Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests for the deleted AbrpApi and HeadUnitWeather removed; new tests for ServerConfig and TelemetryQueue. Lint passes. Not yet tried on the car. Co-Authored-By: Claude Opus 5.5 --- SPECS.md | 56 +-- app/build.gradle | 14 +- app/src/main/AndroidManifest.xml | 8 +- .../com/evsuite/abrp/AbrpUploadService.java | 31 +- .../java/com/evsuite/abrp/ConfigImport.java | 127 ++++++ .../evsuite/abrp/ConsumptionCloudClient.java | 45 +-- .../java/com/evsuite/abrp/MainActivity.java | 379 +++++++++++++++++- .../java/com/evsuite/abrp/SecurePrefs.java | 48 +++ .../java/com/evsuite/abrp/ServerConfig.java | 100 +++++ .../com/evsuite/abrp/TelemetryPayload.java | 213 ++++++++++ .../java/com/evsuite/abrp/TelemetryQueue.java | 59 +++ .../com/evsuite/abrp/TelemetryUploader.java | 264 ++++++++++++ .../java/com/evsuite/abrp/UploadCadence.java | 66 +++ .../java/com/evsuite/abrp/UploadSettings.java | 77 +++- app/src/main/res/drawable/ic_tab_server.xml | 23 ++ .../main/res/layout-w1000dp/activity_main.xml | 1 + .../main/res/layout-w1000dp/pane_service.xml | 5 +- app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/layout/pane_server.xml | 30 ++ app/src/main/res/layout/pane_service.xml | 5 +- app/src/main/res/values/strings.xml | 48 +-- app/src/main/res/values/strings_design.xml | 8 +- .../java/com/evsuite/abrp/AbrpApiTest.java | 50 --- .../com/evsuite/abrp/ConfigImportTest.java | 23 +- .../com/evsuite/abrp/HeadUnitWeatherTest.java | 109 ----- .../com/evsuite/abrp/ServerConfigTest.java | 47 +++ .../com/evsuite/abrp/TelemetryQueueTest.java | 40 ++ 27 files changed, 1590 insertions(+), 287 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/ConfigImport.java create mode 100644 app/src/main/java/com/evsuite/abrp/SecurePrefs.java create mode 100644 app/src/main/java/com/evsuite/abrp/ServerConfig.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryPayload.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryQueue.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryUploader.java create mode 100644 app/src/main/java/com/evsuite/abrp/UploadCadence.java create mode 100644 app/src/main/res/drawable/ic_tab_server.xml create mode 100644 app/src/main/res/layout/pane_server.xml delete mode 100644 app/src/test/java/com/evsuite/abrp/AbrpApiTest.java delete mode 100644 app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/ServerConfigTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java diff --git a/SPECS.md b/SPECS.md index a315240..3991e21 100644 --- a/SPECS.md +++ b/SPECS.md @@ -27,7 +27,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite | Measurement | Timestamp | Tags | Fields | |---|---|---|---| | `telemetry` | sample time | — | soc, power, speed, lat, lon, heading, elevation, is_charging, is_dcfc, is_parked, capacity, soe, odometer, est_battery_range, batt_temp, cabin_temp, ext_temp, hvac_setpoint, voltage, current, tire_pressure_fl/fr/rl/rr | - | `consumption_daily` | the day, 00:00 UTC | — | km, kwh, hours, socDrop, sohPercent | + | `consumption_chunk` | recordedAt | date | km, kwh, hours, socDrop, sohPercent (days summed on read) | | `charging_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent | | `charging_curve` | point time | sessionId | powerKw, socPercent | @@ -40,15 +40,16 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite retried, since stale live data is useless to ABRP. Off when the token is unset. - **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`, `GET /v1/consumption/history/:id`, the dashboard and `/v1/health`. -- **Self-hosting:** drop the Vercel assumptions, `output: "standalone"`, a `Dockerfile`, and - `ensureIndexes()` replaced by bucket checks at startup. +- **Self-hosting:** `output: "standalone"`, a `Dockerfile`, and InfluxDB's own overwrite semantics + instead of `ensureIndexes()`. ### Car app - Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`, `UploadCadence`, location access), sending to the server instead of ABRP. -- Server URL and ingest token: open question, see below. -- Generate our own `CONSUMPTION_INSTALLATION_ID`. +- Server URL, ingest token and installation id are entered in a new **Server** tab or imported from + a config file; nothing about the server is compiled into the APK. Each installation generates + its own id. ### Image and deployment @@ -59,15 +60,23 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite with placeholder values only. - **Nothing is ever applied to the cluster by us.** Nils applies the manifests. -### Open questions +### Decisions (2026-10-17) -- [ ] Run InfluxDB inside the cluster from our manifests (assumed), or use an existing instance? -- [ ] Ingress class and TLS setup (e.g. cert-manager `ClusterIssuer` name), or an existing wildcard - certificate Secret? -- [ ] Car config: bake the server URL and ingest token into the APK at build time (as upstream - does), or enter them in the app, which restores the settings tab with the config-file import? - In the app keeps the token out of the APK. -- [ ] ABRP API key: the open-source default key from upstream, or our own? +- InfluxDB 2 runs in the cluster from our manifests. +- TLS: cert-manager `ClusterIssuer` `letsencrypt-prod`; Traefik `IngressRoute` on `websecure`. +- Car config is entered in the app (Server tab / config file), not baked into the APK. +- The ABRP API key and user token are set by Nils in the `mg4-server` Secret. + +### Status + +- [x] Server on InfluxDB 2 with `/v1/telemetry` and the ABRP forwarder (`web/`), tested locally + against InfluxDB 2.7.12. Image builds. +- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). +- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. + Unit tests and lint pass; **not yet tried on the car**. +- [ ] Push the image to git.stinnesbeck.com (needs a registry login on the build machine). +- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). +- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. ## To do @@ -81,19 +90,12 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite (`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the response parsing checked against that server's releases API. Release assets must be named `NilsMG4-unstable-.apk` with a `.sha256` sidecar (`build-car` writes both). -- [ ] **ABRP upload is gone upstream.** Upstream commit `ea8032a` ("chore", 2026-10-09) removed the - ABRP tab, the config-file import (`ConfigImport`), the upload (`AbrpApi`, `TelemetryPayload`, - `UploadCadence`), `SecurePrefs` and `HeadUnitWeather`. The app now only tracks consumption - and syncs it to the MongoDB API. Leftover `strings.xml` entries (`btn_import`, `import_*`, - `hint_api_key`, …) belong to it. Decide whether to restore it. -- [ ] **Fix the unit tests.** They don't compile upstream: `AbrpApiTest`, `TelemetryPayloadTest` - and others still use `AbrpApi`/`TelemetryPayload`, which upstream commit `ea8032a` deleted. - [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in `pane_charging.xml` and `MainActivity.java`. -- [ ] **Cloud consumption sync.** `CONSUMPTION_INSTALLATION_ID` in `app/build.gradle` is a fixed - UUID inherited from upstream. Set our own before configuring `CONSUMPTION_API_URL` / - `CONSUMPTION_API_TOKEN`, so our data cannot land under upstream's id. -- [ ] **Translate the rest.** The `web/` dashboard (Next.js) and docs (`CHANGELOG.md`, `OTA.md`'s +- [ ] **Dashboard language and branding.** The `web/` pages are still Turkish, say "FatihsMG4" and + show prices in lira. +- [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only. +- [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. - [ ] **Docs still use the old package ID.** `README.md`, `OTA.md`, `CHANGELOG.md`, `mise.toml` and `docs/MG4_PLATFORM_INSTALL.md` refer to `com.evsuite.abrp(.unstable)` and `FatihsMG4-*.apk`. @@ -109,6 +111,12 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite - 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 — 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. diff --git a/app/build.gradle b/app/build.gradle index a82f37f..0c0fc65 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,17 +34,11 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 56 - versionName "2.2.39" + versionCode 57 + versionName "2.3.0" - // MongoDB credentials never ship in the APK. The app only talks to the small - // authenticated API in server/, which owns the database connection. - buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\"" - buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\"" - // Fixed installation identity: baked into every build so an APK wipe cannot orphan - // the cloud history under a new random UUID. The car's own consumption history lives - // under this id in the daily_consumption collection. - buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\"" + // Nothing about the server ships in the APK: its URL and ingest token are entered in + // the app's Server tab (ServerConfig), and the database is only reachable by the server. testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 608eaba..36b8413 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ + + + + + + + + + diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index c1cae25..bd06d3b 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -10,6 +10,7 @@ + diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 9eced4d..0d904ea 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -3,12 +3,9 @@ - - + - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4a92f4f..a0ea4c8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -17,6 +17,7 @@ + diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml new file mode 100644 index 0000000..455efa2 --- /dev/null +++ b/app/src/main/res/layout/pane_server.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index 9d8557f..cfdda55 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -3,12 +3,9 @@ - - + - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 341259d..ef89248 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ Nils\' MG4 - ABRP Upload Overview Consumption @@ -11,6 +10,7 @@ Since start, motor, lifetime and trip counters Charging Live battery values and SOC/power curve + Server Wi-Fi Logs @@ -42,45 +42,45 @@ Front Right Rear Left Rear Right - Configuration - ABRP API Key - 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d - Default: open-source ABRP telemetry key. You may replace it with your own. - Enter your ABRP API key first - ABRP User Token - Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry) - Show or hide API key - Show or hide user token Save Test connection Import from file + + Car server + Server URL + For example https://car.stinnesbeck.com + Ingest token + The server\'s INGEST_TOKEN, at least 24 characters + Installation ID + Your data is stored under this ID. Keep it when reinstalling. + Enter an https:// URL + The token must be at least 24 characters, without spaces + Not a valid ID (UUID) + Server settings saved + Server not configured — open the Server tab + Put a text file with server_url and token in %1$s (on the head unit or a USB stick in the same folder), then press Import. + - Config imported — press Test to verify + Config imported and saved — press Test to verify Could not read that file - No ABRP settings found in that file + No settings found in that file No config file found. Copy it to %1$s Testing… - Connected — API responding OK - Auth failed — check your API key and token + Connected — server accepts the token + Token rejected — check it against the server\'s INGEST_TOKEN Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP. Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings. Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings. No internet connection Connection timed out Server error (HTTP %1$d) - Enter your ABRP token first - Upload Service - Enable background telemetry - Upload telemetry to ABRP - Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running. - Start automatically with the car - Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen. + Send live telemetry to the server + The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running. - Service not running Upload frequency @@ -100,8 +100,8 @@ Stopped Starting… Running - Tracking locally — ABRP upload off - Tracking locally — ABRP upload off + Tracking locally — live telemetry off + Tracking locally — live telemetry off In error — last %1$d uploads failed diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 8cce1d3..dbadaea 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -7,8 +7,8 @@ ON THE ROAD, ELECTRIC Your drive at a glance. Data from your car, all in one place. - Connect to your route. - Link your car with your ABRP account. + Where your data goes. + Your own server stores it and passes it on to ABRP. You focus on the road. Your data stays up to date in the background. Stay connected. @@ -18,9 +18,9 @@ BATTERY ENERGY AND TEMPERATURE TIRE PRESSURES - ACCOUNT LINK + SERVER LINK Your private connection. - The token links your car to your ABRP account. Your credentials are stored encrypted on this device. + The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server. Values that cannot be read are shown as —. UPLOAD CONTROL UPDATE FREQUENCY diff --git a/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java b/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java deleted file mode 100644 index 6852511..0000000 --- a/app/src/test/java/com/evsuite/abrp/AbrpApiTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.evsuite.abrp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * [T-912] Credentials must reach ABRP in the request body. A URL is logged by - * proxies, CDNs and crash reporters; a body is not. - */ -public class AbrpApiTest { - - @Test - public void endpointUrlCarriesNoCredentials() { - assertFalse(AbrpApi.API_URL.contains("?")); - assertFalse(AbrpApi.API_URL.contains("api_key")); - assertFalse(AbrpApi.API_URL.contains("token")); - } - - @Test - public void endpointIsHttps() { - assertTrue(AbrpApi.API_URL.startsWith("https://")); - assertTrue(AbrpApi.VERIFY_URL.startsWith("https://")); - } - - /** [T-913] The Test button must not be able to write telemetry. */ - @Test - public void credentialCheckDoesNotTargetTheTelemetryWriteEndpoint() { - assertFalse("le test de connexion ne doit jamais viser /tlm/send", - AbrpApi.VERIFY_URL.contains("/tlm/send")); - assertTrue(AbrpApi.VERIFY_URL.contains("get_next_charge")); - } - - @Test - public void bodyCarriesAllThreeParameters() throws Exception { - String body = AbrpApi.formBody("KEY", "TOK", "{\"utc\":1}"); - assertTrue(body.contains("api_key=KEY")); - assertTrue(body.contains("token=TOK")); - assertTrue(body.contains("tlm=")); - } - - @Test - public void bodyIsFormEncoded() throws Exception { - // A token with URL-significant characters must not break the body layout. - String body = AbrpApi.formBody("a+b/c", "d&e=f", "{\"utc\":1}"); - assertEquals("api_key=a%2Bb%2Fc&token=d%26e%3Df&tlm=%7B%22utc%22%3A1%7D", body); - } -} diff --git a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java index 61dff8f..bde9e65 100644 --- a/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java +++ b/app/src/test/java/com/evsuite/abrp/ConfigImportTest.java @@ -8,34 +8,37 @@ import static org.junit.Assert.assertTrue; import org.junit.Test; /** - * ABRP config file parsing — pure JVM, no Android needed. + * Config file parsing — pure JVM, no Android needed. * - * The file exists so a user does not have to type a long api_key and token on the car's + * The file exists so a user does not have to type a long server URL and token on the car's * on-screen keyboard, so the parser must be forgiving about whitespace, case and comments * while never inventing a value the file did not set. */ public class ConfigImportTest { @Test - public void parsesCredentials() { - ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n"); - assertEquals("abc123", c.apiKey); + public void parsesServerSettings() { + ConfigImport c = ConfigImport.parse("server_url = https://car.example.com\ntoken = def456\n" + + "installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b\n"); + assertEquals("https://car.example.com", c.serverUrl); assertEquals("def456", c.token); + assertEquals("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", c.installationId); assertFalse(c.isEmpty()); } @Test public void ignoresCommentsBlankLinesAndCase() { ConfigImport c = ConfigImport.parse( - "# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n"); - assertEquals("key", c.apiKey); + "# my config\n\n SERVER_URL = https://x.example \n# token below\nTOKEN=tok\n"); + assertEquals("https://x.example", c.serverUrl); assertEquals("tok", c.token); } @Test public void absentKeysStayNullSoNothingIsWiped() { ConfigImport c = ConfigImport.parse("token = only-token\n"); - assertNull(c.apiKey); + assertNull(c.serverUrl); + assertNull(c.installationId); assertEquals("only-token", c.token); assertNull(c.intervalSec); assertNull(c.boostLowSoc); @@ -80,8 +83,8 @@ public class ConfigImportTest { @Test public void aliasKeysAreAccepted() { - ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n"); - assertEquals("k", c.apiKey); + ConfigImport c = ConfigImport.parse("url = https://k.example\ningest_token = t\n"); + assertEquals("https://k.example", c.serverUrl); assertEquals("t", c.token); } } diff --git a/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java b/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java deleted file mode 100644 index 2f8f45e..0000000 --- a/app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.evsuite.abrp; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * The broadcast parser, on the JVM. - * - * What is under test is not the vendor's payload — that shape is unverified and may well be - * something else on the car. It is that the parser finds a temperature wherever the payload - * puts it, refuses to invent one, and says what it was given when it fails, because the - * diagnostic is the only thing that reaches a driver with no adb. - */ -public class HeadUnitWeatherTest { - - @Test - public void readsATemperatureAndThePlaceItIsFor() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(( - "{\"code\":0,\"weather\":{\"temperature\":27,\"unit\":\"C\"," - + "\"address\":\"Toulouse\",\"conditionType\":1}}")); - assertEquals(27f, weather.temperatureC(), 0.01f); - assertEquals("Toulouse", weather.place()); - // The diagnostic carries the reading's age: a broadcast arrives when the weather app - // refreshes, so "where from" without "how long ago" does not say whether to trust it. - assertEquals("Toulouse/0min", weather.diagnostic()); - } - - @Test - public void findsTheValueWhereverThePayloadNests_it() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(( - "{\"data\":{\"current\":[{\"tempValue\":\"18.5\",\"tempUnit\":\"C\"}]}}")); - assertEquals(18.5f, weather.temperatureC(), 0.01f); - } - - @Test - public void convertsAFahrenheitPayload() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"temperature\":81,\"temperatureUnit\":\"F\"}")); - assertEquals(27.2f, weather.temperatureC(), 0.1f); - } - - @Test - public void anUnnamedUnitIsTakenAsCelsius() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"temp\":21}")); - assertEquals(21f, weather.temperatureC(), 0.01f); - } - - @Test - public void aPayloadWithoutATemperatureReportsWhatItWasGiven() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{\"conditionType\":3,\"address\":\"Toulouse\"}")); - assertNull(weather.temperatureC()); - assertTrue(weather.diagnostic(), weather.diagnostic().startsWith("no temperature in {")); - assertTrue(weather.diagnostic(), weather.diagnostic().contains("conditionType")); - } - - @Test - public void readsThePlaceFromACityKeyToo() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload("{\"temp\":14,\"city\":\"Auzeville\"}"); - assertEquals(14f, weather.temperatureC(), 0.01f); - assertEquals("Auzeville", weather.place()); - } - - @Test - public void aBroadcastWithNothingReadableIsReportedNotGuessed() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(null); - assertNull(weather.temperatureC()); - assertEquals("broadcast carried no JSON", weather.diagnostic()); - } - - @Test - public void beforeAnyBroadcastTheDiagnosticSaysSo() { - HeadUnitWeather weather = new HeadUnitWeather(); - assertNull(weather.temperatureC()); - assertEquals("no broadcast yet", weather.diagnostic()); - } - - @Test - public void malformedJsonDoesNotEscapeTheReceiver() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.acceptPayload(("{not json at all")); - assertNull(weather.temperatureC()); - assertTrue(weather.diagnostic(), weather.diagnostic().contains("reading broadcast")); - } - - @Test - public void aRestoredReadingIsReportedWithItsAge() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.restore(19.5f, "Toulouse", System.currentTimeMillis() - 45 * 60 * 1000L); - assertEquals(19.5f, weather.temperatureC(), 0.01f); - assertEquals("Toulouse/45min", weather.diagnostic()); - } - - @Test - public void anAbsentStickyBroadcastIsNotMistakenForAFailedParse() { - HeadUnitWeather weather = new HeadUnitWeather(); - weather.accept(null); - assertNull(weather.temperatureC()); - assertEquals("none held, none sent yet", weather.diagnostic()); - } -} diff --git a/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java new file mode 100644 index 0000000..f5bf0c8 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ServerConfigTest.java @@ -0,0 +1,47 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** What the Server tab and the config import accept — pure JVM. */ +public class ServerConfigTest { + + @Test + public void urlIsTrimmedAndLosesTrailingSlashes() { + assertEquals("https://car.example.com", ServerConfig.normalizeUrl(" https://car.example.com// ")); + assertEquals("https://car.example.com/base", ServerConfig.normalizeUrl("https://car.example.com/base/")); + } + + @Test + public void plainHttpAndJunkAreRefused() { + // The token travels in a header; over plain http anyone on the hotspot could read it. + assertNull(ServerConfig.normalizeUrl("http://car.example.com")); + assertNull(ServerConfig.normalizeUrl("car.example.com")); + assertNull(ServerConfig.normalizeUrl("https://")); + assertNull(ServerConfig.normalizeUrl("https:///path")); + assertNull(ServerConfig.normalizeUrl("https://user@car.example.com")); + assertNull(ServerConfig.normalizeUrl("https://car.example.com/?x=1")); + assertNull(ServerConfig.normalizeUrl(null)); + } + + @Test + public void tokenMustMeetTheServersMinimum() { + assertTrue(ServerConfig.isValidToken("0123456789abcdef01234567")); + assertFalse(ServerConfig.isValidToken("too-short")); + assertFalse(ServerConfig.isValidToken("0123456789abcdef 01234567")); + assertFalse(ServerConfig.isValidToken(null)); + } + + @Test + public void installationIdMustBeAUuidTheServerAccepts() { + assertTrue(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b")); + assertTrue(ServerConfig.isValidInstallationId(java.util.UUID.randomUUID().toString())); + assertFalse(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-0f60-8a9b-0c1d2e3f4a5b")); // version 0 + assertFalse(ServerConfig.isValidInstallationId("not-a-uuid")); + assertFalse(ServerConfig.isValidInstallationId("")); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java new file mode 100644 index 0000000..823a491 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java @@ -0,0 +1,40 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Test; + +/** The offline buffer for live samples — pure JVM. */ +public class TelemetryQueueTest { + + @Test + public void batchesOldestFirstAndKeepsThemUntilConfirmed() { + TelemetryQueue queue = new TelemetryQueue(); + queue.add("a"); + queue.add("b"); + assertEquals(List.of("a", "b"), queue.peekBatch()); + // Not confirmed yet: a failed request must not lose them. + assertEquals(2, queue.size()); + queue.remove(2); + assertEquals(0, queue.size()); + } + + @Test + public void aBatchNeverExceedsWhatTheServerAccepts() { + TelemetryQueue queue = new TelemetryQueue(); + for (int i = 0; i < 1200; i++) queue.add(String.valueOf(i)); + List batch = queue.peekBatch(); + assertEquals(TelemetryQueue.MAX_BATCH, batch.size()); + assertEquals("0", batch.get(0)); + } + + @Test + public void whenFullTheOldestSamplesAreDropped() { + TelemetryQueue queue = new TelemetryQueue(3); + for (String s : new String[]{"a", "b", "c", "d", "e"}) queue.add(s); + assertEquals(List.of("c", "d", "e"), queue.peekBatch()); + assertEquals(2, queue.dropped()); + } +}