Skip to content

Commit c97170d

Browse files
malysclaude
andcommitted
feat(chargepilot): CP-080 the trip monitor comes back with the car
Automatic detection was automatic only for whoever had opened the app since the last boot. TripRecordingService already outlives the dashboard — it is a started foreground service, which is why a trip survives the driver switching to the media app — but the head unit shuts the process down with the car and nothing started it again. Measured: the odometer moved 148 km between the 2026-09-17 and 2026-09-24 probe bundles while the trip history recorded 32 km of it. Every threshold downstream is denominated in recorded kilometres — the consumption fit wants twelve segments and has four, the health estimate wants six discharges of 25 points and has none, and the eco verdict can only judge a drive somebody recorded. TripMonitorBootReceiver restarts the monitor on BOOT_COMPLETED and on MY_PACKAGE_REPLACED, and only when automatic detection is enabled. Both are protected broadcasts, so exported="true" is safe here in a way the steering-wheel receiver elsewhere in this suite is not; the action is checked regardless. The receiver catches its own failures, because a crash in a boot receiver is the kind of instability this suite may not introduce on a head unit. What it costs is bounded by the service that was already there: 1 Hz, a low-importance notification, no disk write until a completed trip is stored, and MISSING_SPEED_SAMPLE_LIMIT suspends everything after ten consecutive reads with no usable speed — which is what a head unit that booted without the vehicle interfaces looks like. RECEIVE_BOOT_COMPLETED is install-time, reaches no vehicle interface and carries no data; it is in the permission allowlist with that reasoning. EVHardware moves to master HEAD (bcbd55a) in the same commit. Not compiled: no JDK or Android SDK in this workspace. The on-vehicle check is the only one that proves this — drive without ever opening the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ebb115c commit c97170d

4 files changed

Lines changed: 83 additions & 1 deletion

File tree

‎.github/security/permission-allowlist.txt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ android.car.permission.CAR_VENDOR_EXTENSION
88
# Install-time permissions, no vehicle access, no network.
99
android.permission.FOREGROUND_SERVICE
1010
android.permission.FOREGROUND_SERVICE_SPECIAL_USE
11+
# CP-080, 2026-09-24. The monitor is restarted after a head-unit boot and after this app is
12+
# updated, because the process dies with the car and until now only opening the app brought
13+
# recording back — 116 of 148 km driven in one measured week were never recorded. Install-time,
14+
# no vehicle access, no network, no data. It starts the same monitor the dashboard starts, and
15+
# that monitor suspends itself after ten consecutive reads with no usable speed.
16+
android.permission.RECEIVE_BOOT_COMPLETED
1117
# Reviewed and approved 2026-09-04 (CP-043). The first two are declared in the main
1218
# manifest as of the same day, by the owner's decision, ahead of the routing client that
1319
# will use them; the third is not, and stays undeclared until a service reads a position.

‎app/src/main/AndroidManifest.xml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3131
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
3232

33+
<!-- CP-080. Automatic trip detection is worth nothing if it only starts when somebody opens
34+
the app: the head unit shuts this process down with the car, and 116 of 148 km driven
35+
between two probe bundles were never recorded for exactly that reason. This is an
36+
install-time permission, it reaches no vehicle interface and it carries no data; what it
37+
starts is the same monitor the dashboard starts, which suspends itself after ten reads
38+
with no usable speed. -->
39+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
40+
3341
<!-- Route planning. Reviewed and accepted by the owner under CP-043 on 2026-09-04; the
3442
reasoning, the engine choice and the privacy boundary are in
3543
analysis/CP-043_network_and_location.md, and these two lines are meaningless without
@@ -155,6 +163,19 @@
155163
android:resource="@xml/file_paths" />
156164
</provider>
157165

166+
<!-- exported, because a manifest receiver is not handed a system broadcast otherwise.
167+
Both actions are protected broadcasts — only the platform may send them — and the
168+
receiver checks the action regardless. -->
169+
<receiver
170+
android:name=".TripMonitorBootReceiver"
171+
android:enabled="true"
172+
android:exported="true">
173+
<intent-filter>
174+
<action android:name="android.intent.action.BOOT_COMPLETED" />
175+
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
176+
</intent-filter>
177+
</receiver>
178+
158179
<service
159180
android:name=".TripRecordingService"
160181
android:exported="false"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import com.evsuite.hardware.AppLogger
7+
8+
/**
9+
* Restores automatic trip monitoring after a head-unit start, so recording is automatic in fact.
10+
*
11+
* [TripRecordingService] already outlives the dashboard — it is a *started* foreground service,
12+
* which is the whole reason a trip survives the driver switching to the media app. What it does
13+
* not survive is the car being switched off: the head unit shuts the process down with
14+
* everything else, and until CP-074 nothing started it again. The monitor therefore came back
15+
* only when somebody opened the app, and a drive begun without opening it was a drive nobody
16+
* recorded.
17+
*
18+
* CP-080. Measured, which is why this exists: the odometer moved 148 km between the 2026-09-17 and
19+
* 2026-09-24 bundles while the trip history recorded 32 km of it. Every threshold downstream is
20+
* paid for in recorded kilometres — the consumption fit needs a span of speeds, the health
21+
* estimate needs complete discharges the app watched, and eco-driving needs the drive it is
22+
* scoring — so coverage is upstream of all of them.
23+
*
24+
* **What it costs is bounded by the service, not by a promise here.** With automatic detection
25+
* off, nothing starts. With it on, the service samples at 1 Hz and suspends itself after ten
26+
* consecutive reads with no usable speed, which is what a head unit that booted without the
27+
* vehicle interfaces looks like. It writes to disk only when a completed trip is stored.
28+
*
29+
* `exported` is true because a manifest receiver does not hear a system broadcast otherwise,
30+
* and it is safe here for the reason the steering-wheel receiver elsewhere in this suite is
31+
* not: `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED` are protected broadcasts, which only the
32+
* platform may send. The action is checked anyway — a receiver that acts on whatever arrives is
33+
* a receiver that trusted its manifest.
34+
*/
35+
class TripMonitorBootReceiver : BroadcastReceiver() {
36+
37+
override fun onReceive(context: Context, intent: Intent?) {
38+
val action = intent?.action
39+
if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) {
40+
return
41+
}
42+
if (!TripRecordingService.isAutomaticDetectionEnabled(context)) {
43+
AppLogger.i(TAG, "boot: automatic trip detection is off, nothing started")
44+
return
45+
}
46+
// A failure here must not take the platform's boot broadcast down with it: the service
47+
// is unavailable on a device still unlocking, and a crash in a boot receiver is the
48+
// kind of instability this app is not permitted to introduce on a head unit.
49+
runCatching { TripRecordingService.monitorAutomaticTrips(context) }
50+
.onSuccess { AppLogger.i(TAG, "boot: automatic trip monitor started; action=$action") }
51+
.onFailure { AppLogger.w(TAG, "boot: monitor could not start: ${it.message}") }
52+
}
53+
54+
private companion object { const val TAG = "EVChargePilot" }
55+
}

0 commit comments

Comments
 (0)