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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:23:04 +02:00
co-authored by Claude Opus 5.5
parent c253876ed2
commit 6145736c97
27 changed files with 1590 additions and 287 deletions
+32 -24
View File
@@ -27,7 +27,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
| Measurement | Timestamp | Tags | Fields | | 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 | | `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_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent |
| `charging_curve` | point time | sessionId | powerKw, socPercent | | `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. 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`, - **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`,
`GET /v1/consumption/history/:id`, the dashboard and `/v1/health`. `GET /v1/consumption/history/:id`, the dashboard and `/v1/health`.
- **Self-hosting:** drop the Vercel assumptions, `output: "standalone"`, a `Dockerfile`, and - **Self-hosting:** `output: "standalone"`, a `Dockerfile`, and InfluxDB's own overwrite semantics
`ensureIndexes()` replaced by bucket checks at startup. instead of `ensureIndexes()`.
### Car app ### Car app
- Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`, - Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`,
`UploadCadence`, location access), sending to the server instead of ABRP. `UploadCadence`, location access), sending to the server instead of ABRP.
- Server URL and ingest token: open question, see below. - Server URL, ingest token and installation id are entered in a new **Server** tab or imported from
- Generate our own `CONSUMPTION_INSTALLATION_ID`. a config file; nothing about the server is compiled into the APK. Each installation generates
its own id.
### Image and deployment ### Image and deployment
@@ -59,15 +60,23 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
with placeholder values only. with placeholder values only.
- **Nothing is ever applied to the cluster by us.** Nils applies the manifests. - **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? - InfluxDB 2 runs in the cluster from our manifests.
- [ ] Ingress class and TLS setup (e.g. cert-manager `ClusterIssuer` name), or an existing wildcard - TLS: cert-manager `ClusterIssuer` `letsencrypt-prod`; Traefik `IngressRoute` on `websecure`.
certificate Secret? - Car config is entered in the app (Server tab / config file), not baked into the APK.
- [ ] Car config: bake the server URL and ingest token into the APK at build time (as upstream - The ABRP API key and user token are set by Nils in the `mg4-server` Secret.
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. ### Status
- [ ] ABRP API key: the open-source default key from upstream, or our own?
- [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 ## 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 (`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 response parsing checked against that server's releases API. Release assets must be named
`NilsMG4-unstable-<version>.apk` with a `.sha256` sidecar (`build-car` writes both). `NilsMG4-unstable-<version>.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 - [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in
`pane_charging.xml` and `MainActivity.java`. `pane_charging.xml` and `MainActivity.java`.
- [ ] **Cloud consumption sync.** `CONSUMPTION_INSTALLATION_ID` in `app/build.gradle` is a fixed - [ ] **Dashboard language and branding.** The `web/` pages are still Turkish, say "FatihsMG4" and
UUID inherited from upstream. Set our own before configuring `CONSUMPTION_API_URL` / show prices in lira.
`CONSUMPTION_API_TOKEN`, so our data cannot land under upstream's id. - [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only.
- [ ] **Translate the rest.** The `web/` dashboard (Next.js) and docs (`CHANGELOG.md`, `OTA.md`'s - [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s
message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French. 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 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`. `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 - 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 build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing
it, and starts with empty settings and history. 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; - 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. removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
+4 -10
View File
@@ -34,17 +34,11 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 56 versionCode 57
versionName "2.2.39" versionName "2.3.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"
} }
+7 -1
View File
@@ -8,6 +8,11 @@
<!-- 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 <!-- 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 supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
@@ -52,7 +57,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
@@ -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 forwards to ABRP ({@link TelemetryUploader}). The car itself never
* talks to ABRP and holds no ABRP credentials.
* *
* 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,6 +66,7 @@ public class AbrpUploadService extends Service {
private ConsumptionTracker consumptionTracker; private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient; private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker; private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private WifiAutoConnect wifi; private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler; private ScheduledExecutorService scheduler;
private SharedPreferences prefs; private SharedPreferences prefs;
@@ -94,6 +97,7 @@ public class AbrpUploadService extends Service {
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext()); chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext()); wifi = new WifiAutoConnect(getApplicationContext());
telemetryUploader = new TelemetryUploader(getApplicationContext());
scheduler.scheduleWithFixedDelay( scheduler.scheduleWithFixedDelay(
this::safeUploadCycle, this::safeUploadCycle,
@@ -111,15 +115,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 +138,7 @@ 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.stop();
Log.i(TAG, "Service stopped"); Log.i(TAG, "Service stopped");
} }
@@ -159,7 +165,16 @@ public class AbrpUploadService extends Service {
wifi.ensureConnected(false); wifi.ensureConnected(false);
} }
updateNotification("Consumption tracking active"); UploadSettings settings = UploadSettings.from(prefs);
if (!ServerConfig.get(this).isConfigured()) {
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
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");
}
} }
// ---------- Notification ---------- // ---------- Notification ----------
@@ -0,0 +1,127 @@
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.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
* </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;
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
Boolean boostLowSoc, Integer lowSocPercent) {
this.serverUrl = serverUrl;
this.token = token;
this.installationId = installationId;
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
}
/** 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;
}
static ConfigImport parse(String text) {
String serverUrl = null, token = null, installationId = null;
Integer intervalSec = null, lowSocPercent = null;
Boolean boostLowSoc = 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;
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);
}
/** 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;
}
}
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;
}
}
@@ -20,12 +20,13 @@ 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; a
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall * reinstall gets it back through the config file, so the history is not orphaned under a new
* picks up right where the old one left off instead of showing zeroes. * 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 = "NilsMG4.Mongo"; private static final String TAG = "NilsMG4.Mongo";
@@ -37,9 +38,7 @@ final class ConsumptionCloudClient {
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,14 +64,12 @@ 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);
}
} }
/** /**
@@ -87,7 +84,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;
} }
@@ -200,7 +197,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));
@@ -230,7 +227,7 @@ 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)) {
@@ -250,7 +247,7 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null; HttpURLConnection connection = null;
try { try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
String url = trimSlash(endpoint) + path; String url = trimSlash(config().url) + path;
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)"); Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
Log.i(TAG, "Body: " + body.toString()); Log.i(TAG, "Body: " + body.toString());
connection = (HttpURLConnection) new URL(url).openConnection(); connection = (HttpURLConnection) new URL(url).openConnection();
@@ -260,7 +257,7 @@ final class ConsumptionCloudClient {
connection.setDoOutput(true); connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length); connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + token); connection.setRequestProperty("Authorization", "Bearer " + config().token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); } try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode(); int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status); Log.i(TAG, "Response: HTTP " + status);
@@ -295,7 +292,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;
@@ -329,7 +326,7 @@ final class ConsumptionCloudClient {
* 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 not configured"; 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;
@@ -357,12 +354,12 @@ final class ConsumptionCloudClient {
private List<DayRecord> fetchHistory() throws Exception { private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null; HttpURLConnection connection = null;
try { try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId; String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
connection = (HttpURLConnection) new URL(url).openConnection(); connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET"); connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS); connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token); connection.setRequestProperty("Authorization", "Bearer " + config().token);
int status = connection.getResponseCode(); int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status); Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
@@ -16,6 +16,8 @@ import android.widget.TextView;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.app.AppCompatDelegate;
@@ -39,16 +41,18 @@ public class MainActivity extends AppCompatActivity {
/** Navigation destinations in page order. Parallel to {@link #panes}. */ /** Navigation destinations in page order. Parallel to {@link #panes}. */
private static final int[] TAB_IDS = private static final int[] TAB_IDS =
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabWifi, { R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
R.id.tabLog }; R.id.tabWifi, R.id.tabLog };
private static final int[] PAGE_TITLES = { private static final int[] PAGE_TITLES = {
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title, R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_title,
R.string.design_log_title
}; };
private static final int[] PAGE_SUBTITLES = { private static final int[] PAGE_SUBTITLES = {
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle, R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
R.string.design_log_subtitle
}; };
// Status colours come from the palette, not from the Material swatches: #4CAF50 and // Status colours come from the palette, not from the Material swatches: #4CAF50 and
@@ -61,13 +65,14 @@ public class MainActivity extends AppCompatActivity {
private TextView statusText; private TextView statusText;
private TextView callLogText; private TextView callLogText;
private View servicePane; private View servicePane;
private View serverPane;
private View logPane; private View logPane;
private View vehiclePane; private View vehiclePane;
private View consumptionPane; private View consumptionPane;
private View chargingPane; private View chargingPane;
private View wifiPane; private View wifiPane;
/** The six pages in navigation order. Parallel to {@link #TAB_IDS}. */ /** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
private View[] panes; private View[] panes;
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */ /** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
@@ -102,6 +107,28 @@ public class MainActivity extends AppCompatActivity {
private ChargeSessionTracker chargeSessionTracker; private ChargeSessionTracker chargeSessionTracker;
private ChargingGraphView chargeGraph; private ChargingGraphView chargeGraph;
/** Server page. */
private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout;
private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput;
private View connectionStatusRow, connectionIndicator;
private TextView connectionStatusText;
/** Upload page: live telemetry switch and cadence. */
private SwitchMaterial liveTelemetrySwitch, boostSwitch;
private android.widget.Spinner intervalSpinner;
private TextInputLayout lowSocLayout;
private TextInputEditText lowSocInput;
/**
* Picks a config text file. Only reachable on devices that ship a document picker — the
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
* folders first and only falls back to this.
*/
private final ActivityResultLauncher<String[]> configPicker =
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
if (uri != null) importConfig(uri);
});
/** /**
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
* params ViewPager2 requires of its items — inflating detached leaves them null, and * params ViewPager2 requires of its items — inflating detached leaves them null, and
@@ -201,17 +228,22 @@ public class MainActivity extends AppCompatActivity {
// in activity_main.xml — a recycled page would invalidate these references as // in activity_main.xml — a recycled page would invalidate these references as
// soon as the user swiped away from it. // soon as the user swiped away from it.
servicePane = inflatePane(R.layout.pane_service); servicePane = inflatePane(R.layout.pane_service);
serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle); vehiclePane = inflatePane(R.layout.pane_vehicle);
consumptionPane = inflatePane(R.layout.pane_consumption); consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging); chargingPane = inflatePane(R.layout.pane_charging);
wifiPane = inflatePane(R.layout.pane_wifi); wifiPane = inflatePane(R.layout.pane_wifi);
logPane = inflatePane(R.layout.pane_log); logPane = inflatePane(R.layout.pane_log);
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, wifiPane, logPane }; panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
wifiPane, logPane };
bindVehiclePane(); bindVehiclePane();
bindConsumptionPane(); bindConsumptionPane();
bindChargingPane(); bindChargingPane();
bindWifiPane(); bindWifiPane();
bindServerPane();
bindServicePane();
requestLocationPermissionIfNeeded();
statusText = servicePane.findViewById(R.id.status_text); statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text); callLogText = logPane.findViewById(R.id.call_log_text);
@@ -225,7 +257,7 @@ public class MainActivity extends AppCompatActivity {
// a no-op and does not even contain the updater. // a no-op and does not even contain the updater.
UpdateHook.checkInBackground(this); UpdateHook.checkInBackground(this);
// The service is always-on: it tracks consumption and syncs to MongoDB cloud. // The service is always-on: it tracks consumption and sends everything to the server.
// startForegroundService is idempotent — if the service is already up this // startForegroundService is idempotent — if the service is already up this
// is a no-op aside from delivering a new intent. // is a no-op aside from delivering a new intent.
startForegroundService(new Intent(this, AbrpUploadService.class)); startForegroundService(new Intent(this, AbrpUploadService.class));
@@ -337,6 +369,327 @@ public class MainActivity extends AppCompatActivity {
}); });
} }
// ---------- Server page ----------
private void bindServerPane() {
serverUrlLayout = serverPane.findViewById(R.id.server_url_layout);
serverTokenLayout = serverPane.findViewById(R.id.server_token_layout);
installationIdLayout = serverPane.findViewById(R.id.installation_id_layout);
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
installationIdInput = serverPane.findViewById(R.id.installation_id_input);
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
showServerConfig();
((TextView) serverPane.findViewById(R.id.import_hint))
.setText(getString(R.string.import_hint, configDirHint()));
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), textOf(installationIdInput))) {
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
}
});
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
}
private void showServerConfig() {
ServerConfig config = ServerConfig.get(this);
serverUrlInput.setText(config.url);
serverTokenInput.setText(config.token);
installationIdInput.setText(config.installationId);
}
/** Validates, marks the bad fields, and saves only when all three are good. */
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
String url = ServerConfig.normalizeUrl(rawUrl);
String token = rawToken == null ? "" : rawToken.trim();
String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US);
serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null);
serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token));
installationIdLayout.setError(ServerConfig.isValidInstallationId(id)
? null : getString(R.string.server_err_installation_id));
if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false;
ServerConfig.save(this, url, token, id);
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
showServerConfig();
refreshStatus();
return true;
}
/**
* Checks reachability, then the token, without writing anything: the health endpoint
* first, then the car's own history read, which needs the ingest token. Uses what is in
* the fields, saved or not, so a typo can be caught before it is stored.
*/
private void testConnection() {
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
String token = textOf(serverTokenInput);
String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
if (url == null) {
serverUrlLayout.setError(getString(R.string.server_err_url));
return;
}
if (!ServerConfig.isValidInstallationId(id)) {
installationIdLayout.setError(getString(R.string.server_err_installation_id));
return;
}
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
new Thread(() -> {
String message;
int color;
try {
int health = httpGet(url + "/v1/health", null);
if (health != 200) {
message = getString(R.string.conn_err_http, health);
color = COLOR_ERROR;
} else {
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
}
} catch (java.net.SocketTimeoutException e) {
message = getString(R.string.conn_err_timeout);
color = COLOR_ERROR;
} catch (Exception e) {
message = getString(R.string.conn_err_no_internet);
color = COLOR_ERROR;
}
final String m = message;
final int c = color;
runOnUiThread(() -> setConnectionStatus(c, m));
}, "server-test").start();
}
private static int httpGet(String url, String token) throws java.io.IOException {
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
try {
connection.setConnectTimeout(8_000);
connection.setReadTimeout(8_000);
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
return connection.getResponseCode();
} finally {
connection.disconnect();
}
}
private void setConnectionStatus(int color, String message) {
connectionStatusRow.setVisibility(View.VISIBLE);
connectionIndicator.getBackground().mutate().setTint(color);
connectionStatusText.setText(message);
connectionStatusText.setTextColor(color);
}
// ---------- Config import ----------
/**
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
* this app's own folder on internal storage and on every mounted volume including a USB
* stick, and those need no storage permission at any API level. The picker stays as the
* fallback for phones and tablets, where the user may keep the file anywhere.
*/
private void startConfigImport() {
java.io.File found = findConfigFile();
if (found != null) {
importConfig(found);
return;
}
try {
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
// the very file the user is trying to pick. They select it by name instead.
configPicker.launch(new String[]{"*/*"});
} catch (android.content.ActivityNotFoundException e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
}
}
/**
* First readable file in an app-specific folder that parses as a config. Names are not
* filtered: the user copies one file to the folder, whatever they called it.
*/
private java.io.File findConfigFile() {
for (java.io.File dir : getExternalFilesDirs(null)) {
if (dir == null) continue;
java.io.File[] files = dir.listFiles();
if (files == null) continue;
for (java.io.File f : files) {
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
&& !ConfigImport.parse(readText(f)).isEmpty()) {
return f;
}
}
}
return null;
}
/** File text, or "" when unreadable — callers treat that as "not a config". */
private String readText(java.io.File file) {
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
return readCapped(in);
} catch (Exception e) {
return "";
}
}
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
private String readCapped(java.io.InputStream in) throws java.io.IOException {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
}
/** Where the user should drop the file. */
private String configDirHint() {
java.io.File[] dirs = getExternalFilesDirs(null);
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
}
private void importConfig(java.io.File file) {
String text = readText(file);
if (text.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
}
private void importConfig(Uri uri) {
String text;
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) throw new java.io.IOException("no stream");
text = readCapped(in);
} catch (Exception e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
}
/**
* Applies whatever the file sets; absent keys keep their current value, so a file with
* only the token does not wipe the URL. Server settings are validated and saved together;
* cadence keys go to the plain prefs the same way the Upload page writes them.
*/
private void applyConfig(String text) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return;
}
ServerConfig current = ServerConfig.get(this);
if (config.serverUrl != null || config.token != null || config.installationId != null) {
boolean saved = saveServerConfig(
config.serverUrl != null ? config.serverUrl : current.url,
config.token != null ? config.token : current.token,
config.installationId != null ? config.installationId : current.installationId);
if (!saved) {
// Show what the file said, so the marked field is the one to fix in the file.
if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl);
if (config.token != null) serverTokenInput.setText(config.token);
if (config.installationId != null) installationIdInput.setText(config.installationId);
return;
}
}
SharedPreferences.Editor edit = prefs.edit();
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
edit.apply();
bindCadenceControls();
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
refreshStatus();
}
// ---------- Upload page ----------
private void bindServicePane() {
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
boostSwitch = servicePane.findViewById(R.id.boost_switch);
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
refreshStatus();
});
bindCadenceControls();
}
/** The service reads these settings on every tick, so a change applies without a restart. */
private void bindCadenceControls() {
UploadSettings current = UploadSettings.from(prefs);
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
int selected = 0;
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
labels[i] = sec >= 60
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
if (sec == current.intervalSec) selected = i;
}
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
intervalSpinner.setAdapter(adapter);
intervalSpinner.setSelection(selected);
intervalSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
});
boostSwitch.setOnCheckedChangeListener(null);
boostSwitch.setChecked(current.boostLowSoc);
lowSocLayout.setEnabled(current.boostLowSoc);
lowSocInput.setEnabled(current.boostLowSoc);
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
lowSocLayout.setEnabled(checked);
lowSocInput.setEnabled(checked);
});
lowSocInput.setText(String.valueOf(current.lowSocPercent));
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
if (!hasFocus) saveLowSocThreshold();
});
}
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
private void saveLowSocThreshold() {
CharSequence raw = lowSocInput.getText();
int value;
try {
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
} catch (NumberFormatException e) {
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
}
value = Math.max(1, Math.min(99, value));
lowSocInput.setText(String.valueOf(value));
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
}
/**
* GPS for the telemetry position. On the car the app runs as the system user and holds
* it already; this only asks where it does not, such as a phone running the stable build.
*/
private void requestLocationPermissionIfNeeded() {
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
}
}
// ---------- Wi-Fi page ---------- // ---------- Wi-Fi page ----------
private void bindWifiPane() { private void bindWifiPane() {
@@ -764,8 +1117,16 @@ public class MainActivity extends AppCompatActivity {
break; break;
case RUNNING: case RUNNING:
case STARTING: case STARTING:
statusText.setText("Consumption tracking active"); if (!ServerConfig.get(this).isConfigured()) {
statusText.setTextColor(COLOR_OK); statusText.setText(R.string.server_not_configured);
statusText.setTextColor(COLOR_ERROR);
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
statusText.setText(R.string.state_tracking_locally);
statusText.setTextColor(COLOR_PENDING);
} else {
statusText.setText(R.string.state_running);
statusText.setTextColor(COLOR_OK);
}
break; break;
case STOPPED: case STOPPED:
default: default:
@@ -0,0 +1,48 @@
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;
/**
* 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.
*/
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();
instance = EncryptedSharedPreferences.create(
app,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
} catch (Exception e) {
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
}
return instance;
}
}
@@ -0,0 +1,100 @@
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);
/** 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();
}
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: this installation gets its own identity, kept from now on.
id = UUID.randomUUID().toString();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_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(" ");
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
}
@@ -0,0 +1,213 @@
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);
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(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 + "}";
}
return tlm.toString();
}
/**
* 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,59 @@
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.
* Held in memory only — a process restart loses what is queued, which is the price of not
* writing to flash every 15 seconds.
*
* 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; }
synchronized void add(String sampleJson) {
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) {
for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst();
}
synchronized int size() { return samples.size(); }
/** Samples lost to the capacity limit since the queue was created. */
synchronized int dropped() { return dropped; }
}
@@ -0,0 +1,264 @@
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.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
/**
* Live telemetry to the car server, which stores it and forwards the newest sample 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.
*
* 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";
private static final int TIMEOUT_MS = 8_000;
/** 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;
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();
}
// ---------- Tick ----------
void tick(EnergySnapshot vehicle, ChargeSessionTracker.Snapshot session, UploadSettings settings) {
long nowMs = System.currentTimeMillis();
ensureLocationUpdates(settings);
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);
boolean uploadDue = UploadCadence.shouldUpload(
nowMs, lastUploadAttemptMs, parked, charging, soc, stateChanged, settings);
if (!idle || uploadDue) {
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked));
}
if (!uploadDue) return;
// Cadence counts attempts, not successes: retrying every tick while offline would
// spend power exactly when it buys nothing.
lastUploadAttemptMs = nowMs;
flush();
}
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) {
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 = 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() {
ServerConfig config = ServerConfig.get(context);
if (!config.isConfigured()) {
Log.i(TAG, "Telemetry not sent: server not 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 {
int status = post(config, batch);
boolean ok = status >= 200 && status < 300;
AbrpUploadService.log().record(new UploadLog.Entry(System.currentTimeMillis(), status, ok,
ok ? "Telemetry: " + batch.size() + " sample(s) sent"
: "Telemetry rejected (" + batch.size() + " queued)", summary));
if (!ok) {
// A 400 will never succeed on retry: drop the batch rather than block the
// queue behind it forever. Anything else (401, 5xx) is kept for later.
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 int post(ServerConfig config, List<String> batch) throws Exception {
JSONArray samples = new JSONArray();
for (String sample : batch) samples.put(new JSONObject(sample));
byte[] body = new JSONObject()
.put("installationId", config.installationId)
.put("samples", samples)
.toString().getBytes(StandardCharsets.UTF_8);
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(config.url + "/v1/telemetry").openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
connection.setRequestProperty("Content-Type", "application/json");
// The token goes in a header, never the URL, and is never logged.
connection.setRequestProperty("Authorization", "Bearer " + config.token);
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
return connection.getResponseCode();
} finally {
if (connection != null) connection.disconnect();
}
}
// ---------- 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) {
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) {
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());
}
}
void stop() {
try {
if (locationManager != null) locationManager.removeUpdates(locationListener);
} catch (Exception ignored) { }
locationActive = false;
}
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,79 @@
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";
private UploadSettings() { } /**
* 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;
}
} }
@@ -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>
@@ -10,6 +10,7 @@
<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_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" /> <com.google.android.material.button.MaterialButton android:id="@+id/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/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/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/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/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>
@@ -3,12 +3,9 @@
<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" 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>
<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" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
@@ -17,6 +17,7 @@
<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_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/> <com.google.android.material.button.MaterialButton android:id="@+id/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/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/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/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/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>
+30
View File
@@ -0,0 +1,30 @@
<?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="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/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.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>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/installation_id_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_installation_id" app:helperText="@string/helper_installation_id">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/installation_id_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="text" android:singleLine="true" android:textSize="18sp" android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/server_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/server_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" />
<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>
<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">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
<TextView android:id="@+id/import_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Secondary" android:textIsSelectable="true" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+1 -4
View File
@@ -3,12 +3,9 @@
<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" /> <Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
+24 -24
View File
@@ -2,7 +2,6 @@
<string name="app_name">Nils\' MG4</string> <string name="app_name">Nils\' MG4</string>
<!-- Main screen --> <!-- Main screen -->
<string name="tab_abrp">ABRP</string>
<string name="tab_service">Upload</string> <string name="tab_service">Upload</string>
<string name="tab_vehicle">Overview</string> <string name="tab_vehicle">Overview</string>
<string name="tab_consumption">Consumption</string> <string name="tab_consumption">Consumption</string>
@@ -11,6 +10,7 @@
<string name="consumption_subtitle">Since start, motor, lifetime and trip counters</string> <string name="consumption_subtitle">Since start, motor, lifetime and trip counters</string>
<string name="charging_title">Charging</string> <string name="charging_title">Charging</string>
<string name="charging_subtitle">Live battery values and SOC/power curve</string> <string name="charging_subtitle">Live battery values and SOC/power curve</string>
<string name="tab_server">Server</string>
<string name="tab_wifi">Wi-Fi</string> <string name="tab_wifi">Wi-Fi</string>
<string name="tab_log">Logs</string> <string name="tab_log">Logs</string>
@@ -42,45 +42,45 @@
<string name="vehicle_tire_fr">Front Right</string> <string name="vehicle_tire_fr">Front Right</string>
<string name="vehicle_tire_rl">Rear Left</string> <string name="vehicle_tire_rl">Rear Left</string>
<string name="vehicle_tire_rr">Rear Right</string> <string name="vehicle_tire_rr">Rear Right</string>
<string name="card_config">Configuration</string>
<string name="hint_api_key">ABRP API Key</string>
<string name="default_abrp_api_key" translatable="false">8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d</string>
<string name="helper_api_key">Default: open-source ABRP telemetry key. You may replace it with your own.</string>
<string name="api_key_required">Enter your ABRP API key first</string>
<string name="hint_token">ABRP User Token</string>
<string name="helper_token">Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry)</string>
<string name="desc_toggle_api_key">Show or hide API key</string>
<string name="desc_toggle_token">Show or hide user token</string>
<string name="btn_save">Save</string> <string name="btn_save">Save</string>
<string name="btn_test">Test connection</string> <string name="btn_test">Test connection</string>
<string name="btn_import">Import from file</string> <string name="btn_import">Import from file</string>
<!-- Server page -->
<string name="server_card_title">Car server</string>
<string name="hint_server_url">Server URL</string>
<string name="helper_server_url">For example https://car.stinnesbeck.com</string>
<string name="hint_server_token">Ingest token</string>
<string name="helper_server_token">The server\'s INGEST_TOKEN, at least 24 characters</string>
<string name="hint_installation_id">Installation ID</string>
<string name="helper_installation_id">Your data is stored under this ID. Keep it when reinstalling.</string>
<string name="server_err_url">Enter an https:// URL</string>
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
<string name="server_err_installation_id">Not a valid ID (UUID)</string>
<string name="server_saved">Server settings saved</string>
<string name="server_not_configured">Server not configured — open the Server tab</string>
<string name="import_hint">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.</string>
<!-- Config file import --> <!-- Config file import -->
<string name="import_ok">Config imported — press Test to verify</string> <string name="import_ok">Config imported and saved — press Test to verify</string>
<string name="import_err_read">Could not read that file</string> <string name="import_err_read">Could not read that file</string>
<string name="import_err_empty">No ABRP settings found in that file</string> <string name="import_err_empty">No settings found in that file</string>
<string name="import_err_not_found">No config file found. Copy it to %1$s</string> <string name="import_err_not_found">No config file found. Copy it to %1$s</string>
<!-- Connection test --> <!-- Connection test -->
<string name="conn_testing">Testing…</string> <string name="conn_testing">Testing…</string>
<string name="conn_ok">Connected — API responding OK</string> <string name="conn_ok">Connected — server accepts the token</string>
<string name="conn_err_auth">Auth failed — check your API key and token</string> <string name="conn_err_auth">Token rejected — check it against the server\'s INGEST_TOKEN</string>
<string name="perm_denied_location">Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP.</string> <string name="perm_denied_location">Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP.</string>
<string name="perm_denied_car">Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings.</string> <string name="perm_denied_car">Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings.</string>
<string name="perm_denied_all">Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings.</string> <string name="perm_denied_all">Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings.</string>
<string name="conn_err_no_internet">No internet connection</string> <string name="conn_err_no_internet">No internet connection</string>
<string name="conn_err_timeout">Connection timed out</string> <string name="conn_err_timeout">Connection timed out</string>
<string name="conn_err_http">Server error (HTTP %1$d)</string> <string name="conn_err_http">Server error (HTTP %1$d)</string>
<string name="token_required">Enter your ABRP token first</string>
<string name="card_service">Upload Service</string> <string name="label_abrp_upload_enabled">Send live telemetry to the server</string>
<string name="label_upload_enabled">Enable background telemetry</string> <string name="hint_abrp_upload_enabled">The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running.</string>
<string name="label_abrp_upload_enabled">Upload telemetry to ABRP</string>
<string name="hint_abrp_upload_enabled">Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running.</string>
<string name="label_autostart">Start automatically with the car</string>
<string name="hint_autostart">Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen.</string>
<string name="status_stopped">Service not running</string>
<!-- Cadence configuration --> <!-- Cadence configuration -->
<string name="label_frequency">Upload frequency</string> <string name="label_frequency">Upload frequency</string>
@@ -100,8 +100,8 @@
<string name="state_stopped">Stopped</string> <string name="state_stopped">Stopped</string>
<string name="state_starting">Starting…</string> <string name="state_starting">Starting…</string>
<string name="state_running">Running</string> <string name="state_running">Running</string>
<string name="state_tracking_locally">Tracking locally — ABRP upload off</string> <string name="state_tracking_locally">Tracking locally — live telemetry off</string>
<string name="notif_tracking_locally">Tracking locally — ABRP upload off</string> <string name="notif_tracking_locally">Tracking locally — live telemetry off</string>
<string name="state_error">In error — last %1$d uploads failed</string> <string name="state_error">In error — last %1$d uploads failed</string>
<!-- Call log --> <!-- Call log -->
+4 -4
View File
@@ -7,8 +7,8 @@
<string name="design_brand_footer">ON THE ROAD, ELECTRIC</string> <string name="design_brand_footer">ON THE ROAD, ELECTRIC</string>
<string name="design_overview_title">Your drive at a glance.</string> <string name="design_overview_title">Your drive at a glance.</string>
<string name="design_overview_subtitle">Data from your car, all in one place.</string> <string name="design_overview_subtitle">Data from your car, all in one place.</string>
<string name="design_abrp_title">Connect to your route.</string> <string name="design_server_title">Where your data goes.</string>
<string name="design_abrp_subtitle">Link your car with your ABRP account.</string> <string name="design_server_subtitle">Your own server stores it and passes it on to ABRP.</string>
<string name="design_service_title">You focus on the road.</string> <string name="design_service_title">You focus on the road.</string>
<string name="design_service_subtitle">Your data stays up to date in the background.</string> <string name="design_service_subtitle">Your data stays up to date in the background.</string>
<string name="design_wifi_title">Stay connected.</string> <string name="design_wifi_title">Stay connected.</string>
@@ -18,9 +18,9 @@
<string name="design_battery">BATTERY</string> <string name="design_battery">BATTERY</string>
<string name="design_energy">ENERGY AND TEMPERATURE</string> <string name="design_energy">ENERGY AND TEMPERATURE</string>
<string name="design_tires">TIRE PRESSURES</string> <string name="design_tires">TIRE PRESSURES</string>
<string name="design_account">ACCOUNT LINK</string> <string name="design_account">SERVER LINK</string>
<string name="design_private_title">Your private connection.</string> <string name="design_private_title">Your private connection.</string>
<string name="design_private_body">The token links your car to your ABRP account. Your credentials are stored encrypted on this device.</string> <string name="design_private_body">The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server.</string>
<string name="design_no_data">Values that cannot be read are shown as &#x2014;.</string> <string name="design_no_data">Values that cannot be read are shown as &#x2014;.</string>
<string name="design_service_controls">UPLOAD CONTROL</string> <string name="design_service_controls">UPLOAD CONTROL</string>
<string name="design_service_timing">UPDATE FREQUENCY</string> <string name="design_service_timing">UPDATE FREQUENCY</string>
@@ -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);
}
}
@@ -8,34 +8,37 @@ import static org.junit.Assert.assertTrue;
import org.junit.Test; 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 * on-screen keyboard, so the parser must be forgiving about whitespace, case and comments
* while never inventing a value the file did not set. * while never inventing a value the file did not set.
*/ */
public class ConfigImportTest { public class ConfigImportTest {
@Test @Test
public void parsesCredentials() { public void parsesServerSettings() {
ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n"); ConfigImport c = ConfigImport.parse("server_url = https://car.example.com\ntoken = def456\n"
assertEquals("abc123", c.apiKey); + "installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b\n");
assertEquals("https://car.example.com", c.serverUrl);
assertEquals("def456", c.token); assertEquals("def456", c.token);
assertEquals("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", c.installationId);
assertFalse(c.isEmpty()); assertFalse(c.isEmpty());
} }
@Test @Test
public void ignoresCommentsBlankLinesAndCase() { public void ignoresCommentsBlankLinesAndCase() {
ConfigImport c = ConfigImport.parse( ConfigImport c = ConfigImport.parse(
"# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n"); "# my config\n\n SERVER_URL = https://x.example \n# token below\nTOKEN=tok\n");
assertEquals("key", c.apiKey); assertEquals("https://x.example", c.serverUrl);
assertEquals("tok", c.token); assertEquals("tok", c.token);
} }
@Test @Test
public void absentKeysStayNullSoNothingIsWiped() { public void absentKeysStayNullSoNothingIsWiped() {
ConfigImport c = ConfigImport.parse("token = only-token\n"); ConfigImport c = ConfigImport.parse("token = only-token\n");
assertNull(c.apiKey); assertNull(c.serverUrl);
assertNull(c.installationId);
assertEquals("only-token", c.token); assertEquals("only-token", c.token);
assertNull(c.intervalSec); assertNull(c.intervalSec);
assertNull(c.boostLowSoc); assertNull(c.boostLowSoc);
@@ -80,8 +83,8 @@ public class ConfigImportTest {
@Test @Test
public void aliasKeysAreAccepted() { public void aliasKeysAreAccepted() {
ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n"); ConfigImport c = ConfigImport.parse("url = https://k.example\ningest_token = t\n");
assertEquals("k", c.apiKey); assertEquals("https://k.example", c.serverUrl);
assertEquals("t", c.token); assertEquals("t", c.token);
} }
} }
@@ -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());
}
}
@@ -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(""));
}
}
@@ -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<String> 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());
}
}