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
@@ -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;
/**
* ABRP config file parsing — pure JVM, no Android needed.
* Config file parsing — pure JVM, no Android needed.
*
* The file exists so a user does not have to type a long api_key and token on the car's
* The file exists so a user does not have to type a long server URL and token on the car's
* on-screen keyboard, so the parser must be forgiving about whitespace, case and comments
* while never inventing a value the file did not set.
*/
public class ConfigImportTest {
@Test
public void parsesCredentials() {
ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n");
assertEquals("abc123", c.apiKey);
public void parsesServerSettings() {
ConfigImport c = ConfigImport.parse("server_url = https://car.example.com\ntoken = def456\n"
+ "installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b\n");
assertEquals("https://car.example.com", c.serverUrl);
assertEquals("def456", c.token);
assertEquals("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", c.installationId);
assertFalse(c.isEmpty());
}
@Test
public void ignoresCommentsBlankLinesAndCase() {
ConfigImport c = ConfigImport.parse(
"# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n");
assertEquals("key", c.apiKey);
"# my config\n\n SERVER_URL = https://x.example \n# token below\nTOKEN=tok\n");
assertEquals("https://x.example", c.serverUrl);
assertEquals("tok", c.token);
}
@Test
public void absentKeysStayNullSoNothingIsWiped() {
ConfigImport c = ConfigImport.parse("token = only-token\n");
assertNull(c.apiKey);
assertNull(c.serverUrl);
assertNull(c.installationId);
assertEquals("only-token", c.token);
assertNull(c.intervalSec);
assertNull(c.boostLowSoc);
@@ -80,8 +83,8 @@ public class ConfigImportTest {
@Test
public void aliasKeysAreAccepted() {
ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n");
assertEquals("k", c.apiKey);
ConfigImport c = ConfigImport.parse("url = https://k.example\ningest_token = t\n");
assertEquals("https://k.example", c.serverUrl);
assertEquals("t", c.token);
}
}
@@ -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());
}
}