diff --git a/AGENTS.md b/AGENTS.md
index c5642d4..d45ea11 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,7 +68,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents |
|---|---|
-| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread |
+| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread |
| Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
diff --git a/SPECS.md b/SPECS.md
index 3f5e586..5ba7203 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -118,6 +118,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
+- 2026-10-23 — App 2.20.0: the Overview's Vehicle info card shows the lease mileage: "Lease
+ allowance today" (40,000 km over four years from 2023-05-01, accruing evenly per day, in
+ `LeaseAllowance`, unit-tested) and "Versus allowance", the odometer against it ("… km over" /
+ "… km under", "—" when the odometer is unread). The odometer now has a thousands separator,
+ like the new rows. Checked on the emulator with a sample odometer of 25,000 km in a throwaway
+ build (34,798 km allowed on 2026-10-23, 9,798 km under); the Overview still fits.
- 2026-10-23 — App 2.19.9: the Foreground apps card is gone from the Logs tab (it found CarPlay's
package; nothing left to find), with ForegroundProbe and ForegroundHistory. The Logs tab is one
card again — update log, then uploads — with the other tabs' page padding.
diff --git a/app/build.gradle b/app/build.gradle
index 410c5b4..df5f0ec 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
- versionCode 108
- versionName "2.19.9"
+ versionCode 109
+ versionName "2.20.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
diff --git a/app/src/main/java/com/evsuite/abrp/LeaseAllowance.java b/app/src/main/java/com/evsuite/abrp/LeaseAllowance.java
new file mode 100644
index 0000000..2aa5c56
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/LeaseAllowance.java
@@ -0,0 +1,31 @@
+package com.evsuite.abrp;
+
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+
+/**
+ * The lease's mileage allowance: 40,000 km over four years from 1 May 2023, accruing evenly per
+ * day. The Overview's Vehicle info card shows what is allowed by today and how far the odometer
+ * is above or below it. Pure Java, so it is unit-tested.
+ */
+final class LeaseAllowance {
+
+ static final LocalDate START = LocalDate.of(2023, 5, 1);
+ static final LocalDate END = START.plusYears(4);
+ static final double TOTAL_KM = 40_000d;
+
+ private LeaseAllowance() {}
+
+ /** Kilometres allowed by {@code today}: none before the start, all of them from the end on. */
+ static double allowedKm(LocalDate today) {
+ long total = ChronoUnit.DAYS.between(START, END);
+ long elapsed = Math.max(0L, Math.min(total, ChronoUnit.DAYS.between(START, today)));
+ return TOTAL_KM * elapsed / total;
+ }
+
+ /** Odometer minus allowance: positive over, negative under; null when the odometer is unread. */
+ static Double differenceKm(Float odometerKm, LocalDate today) {
+ if (odometerKm == null || !Float.isFinite(odometerKm)) return null;
+ return odometerKm - allowedKm(today);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index 19889be..0ba1795 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -85,7 +85,7 @@ public class MainActivity extends AppCompatActivity {
/** Used only on the overview-read thread. */
private EnergyTelemetryReader vehicleReader;
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
- vehCharging, vehHvac,
+ vehCharging, vehHvac, vehLeaseAllowance, vehLeaseDifference,
vehTireFl, vehTireFr, vehTireRl, vehTireRr,
vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote,
vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote,
@@ -1243,6 +1243,8 @@ public class MainActivity extends AppCompatActivity {
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
+ vehLeaseAllowance = vehiclePane.findViewById(R.id.vehicle_lease_allowance);
+ vehLeaseDifference = vehiclePane.findViewById(R.id.vehicle_lease_difference);
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
@@ -1386,13 +1388,30 @@ public class MainActivity extends AppCompatActivity {
}
}
+ /** The lease allowance by today and the odometer against it (LeaseAllowance). */
+ private void showLease(Float odometerKm) {
+ java.time.LocalDate today = java.time.LocalDate.now();
+ // Locale.US like fmt(), so the card's numbers share one format.
+ vehLeaseAllowance.setText(String.format(java.util.Locale.US, "%,.0f km",
+ LeaseAllowance.allowedKm(today)));
+ Double diff = LeaseAllowance.differenceKm(odometerKm, today);
+ if (diff == null) {
+ vehLeaseDifference.setText(R.string.no_value);
+ } else {
+ String km = String.format(java.util.Locale.US, "%,.0f", Math.abs(diff));
+ vehLeaseDifference.setText(Math.round(diff) == 0 ? getString(R.string.lease_on_target)
+ : getString(diff > 0 ? R.string.lease_over : R.string.lease_under, km));
+ }
+ }
+
private void showVehicle(EnergySnapshot s) {
try {
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
- vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
+ vehOdometer.setText(fmt(s.getOdometerKm(), "%,.0f km"));
+ showLease(s.getOdometerKm());
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
vehCharging.setText(chargingLabel(s));
diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
index c7a8966..340cd26 100644
--- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml
+++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml
@@ -67,6 +67,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml
index 3245de9..5662991 100644
--- a/app/src/main/res/layout/pane_vehicle.xml
+++ b/app/src/main/res/layout/pane_vehicle.xml
@@ -63,6 +63,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 91711e6..95d428f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -48,6 +48,11 @@
Enter a value between 10 and 150 kWh
Speed
Odometer
+ Lease allowance today
+ Versus allowance
+ %1$s km over
+ %1$s km under
+ On target
Battery Temperature
Cabin Temperature
Climate Setpoint
diff --git a/app/src/test/java/com/evsuite/abrp/LeaseAllowanceTest.java b/app/src/test/java/com/evsuite/abrp/LeaseAllowanceTest.java
new file mode 100644
index 0000000..fa648b4
--- /dev/null
+++ b/app/src/test/java/com/evsuite/abrp/LeaseAllowanceTest.java
@@ -0,0 +1,36 @@
+package com.evsuite.abrp;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.time.LocalDate;
+
+import org.junit.Test;
+
+/** The lease mileage allowance — pure JVM. */
+public class LeaseAllowanceTest {
+
+ @Test
+ public void accruesEvenlyPerDay() {
+ assertEquals(0d, LeaseAllowance.allowedKm(LocalDate.of(2023, 5, 1)), 1e-9);
+ // 731 of 1461 days (2024 is a leap year).
+ assertEquals(40_000d * 731 / 1461, LeaseAllowance.allowedKm(LocalDate.of(2025, 5, 1)), 1e-9);
+ assertEquals(40_000d, LeaseAllowance.allowedKm(LocalDate.of(2027, 5, 1)), 1e-9);
+ }
+
+ @Test
+ public void staysWithinTheLease() {
+ assertEquals(0d, LeaseAllowance.allowedKm(LocalDate.of(2023, 1, 1)), 1e-9);
+ assertEquals(40_000d, LeaseAllowance.allowedKm(LocalDate.of(2028, 1, 1)), 1e-9);
+ }
+
+ @Test
+ public void differenceIsOverPositiveUnderNegative() {
+ LocalDate day = LocalDate.of(2025, 5, 1);
+ double allowed = LeaseAllowance.allowedKm(day);
+ assertEquals(1000d, LeaseAllowance.differenceKm((float) (allowed + 1000), day), 0.01);
+ assertEquals(-500d, LeaseAllowance.differenceKm((float) (allowed - 500), day), 0.01);
+ assertNull(LeaseAllowance.differenceKm(null, day));
+ assertNull(LeaseAllowance.differenceKm(Float.NaN, day));
+ }
+}