Skip to content

Commit f04733f

Browse files
malysclaude
andcommitted
feat: recording outlives the screen that started it
The sampler ran between the activity's onStart and onStop, which made the trip log a function of where the driver was looking. Ten minutes in the media app and the accumulator — correctly refusing to integrate across a gap it cannot account for — returned a trip whose distance and energy simply omitted that stretch. TripRecordingService takes over the sampler, the trip session and the history file; MainActivity binds to it and renders what it publishes. Sampling runs while a dashboard is bound or a trip is recording, and the service stops itself when neither holds, so nothing reads vehicle properties on behalf of nobody. Starting is an intent, which is what makes it a started service and lets it outlive the unbind; stopping is a binder call, because the dashboard is by definition on screen when a trip can be stopped. The history append moves off the main thread, and the shutdown waits for it rather than racing the trip the driver just ended. The foreground type is derived from the permission actually held rather than from the manifest, and it is specialUse: the platform's catalogue has no entry for recording a car's own telemetry to local storage, dataSync describes sending it somewhere this app cannot reach, and from Android 15 that type carries a daily cap a long drive would hit. POST_NOTIFICATIONS stays undeclared — the target head unit is API 28, where it does not exist, and a permission never requested on a screen the driver reaches is a permission the app does not have. Verified on the API 28 1920x720 emulator: the service exists while the dashboard is visible, is gone within seconds of leaving it with no trip running, and survives an activity restart without a second sampler. The recording-survives- background path still needs a car: the emulator publishes no speed, so the parked-gated control that starts a trip cannot be reached there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3e971ba commit f04733f

7 files changed

Lines changed: 357 additions & 34 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ android.car.permission.CAR_ENERGY
44
android.car.permission.CAR_EXTERIOR_ENVIRONMENT
55
android.car.permission.CONTROL_CAR_CLIMATE
66
android.car.permission.CAR_VENDOR_EXTENSION
7+
# Trip recording outlives the dashboard, which on API 26+ requires a foreground service.
8+
# Install-time permissions, no vehicle access, no network.
9+
android.permission.FOREGROUND_SERVICE
10+
android.permission.FOREGROUND_SERVICE_SPECIAL_USE

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
66

77
### Added
88

9+
- **A trip keeps recording while the driver looks at something else.** Sampling used to live
10+
between the dashboard's `onStart` and `onStop`, so switching to the media app for ten minutes
11+
left a hole in the middle of the drive: the accumulator refuses to integrate across a gap
12+
longer than five seconds, correctly, since it has no idea what the car did in between, and the
13+
trip came back missing the distance and energy of the part nobody was watching. A foreground
14+
service now owns the sampler and the trip history, and the dashboard is one of its readers. It
15+
samples while a dashboard is bound or a trip is recording, and stops itself when neither is
16+
true, so nothing reads vehicle properties for nobody. The service claims only the foreground
17+
type its held permissions back, and the notification stays a low-importance status line — it
18+
never interrupts a moving car.
19+
920
- **Every figure on the dashboard now says what kind of claim it is.** The energy work ahead is
1021
mostly inference — the MG4 publishes no per-consumer counters, so the climate share, the speed
1122
comparison and the arrival state of charge will all be models — and a model drawn in the same

‎app/src/main/AndroidManifest.xml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@
1616
<uses-permission android:name="android.car.permission.CONTROL_CAR_CLIMATE" />
1717
<uses-permission android:name="android.car.permission.CAR_VENDOR_EXTENSION" />
1818

19+
<!-- Trip recording keeps sampling while the driver looks at another app, which on API 26+
20+
means a foreground service. Both permissions are install-time and neither reaches the
21+
vehicle: FOREGROUND_SERVICE allows the service at all, and FOREGROUND_SERVICE_SPECIAL_USE
22+
backs the one type that fits. The platform's catalogue has no entry for recording a
23+
vehicle's own energy telemetry to local storage — `dataSync` describes transferring data
24+
to somewhere else, which this app has no permission to do and no code for, and from
25+
Android 15 it carries a daily runtime cap a long drive would hit. POST_NOTIFICATIONS is
26+
deliberately absent: the target head unit runs API 28, where it does not exist, and a
27+
permission this app never asks for on a screen the driver reaches is a permission it
28+
does not have. -->
29+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
30+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
31+
1932
<application
2033
android:name=".ChargePilotApp"
2134
android:allowBackup="false"
@@ -36,6 +49,15 @@
3649
<category android:name="android.intent.category.LAUNCHER" />
3750
</intent-filter>
3851
</activity>
52+
53+
<service
54+
android:name=".TripRecordingService"
55+
android:exported="false"
56+
android:foregroundServiceType="specialUse">
57+
<property
58+
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
59+
android:value="Records the vehicle's own energy telemetry to app-private storage for the duration of a trip the driver started. No data leaves the car." />
60+
</service>
3961
</application>
4062

4163
</manifest>

‎app/src/main/java/com/evsuite/chargepilot/MainActivity.kt‎

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package com.evsuite.chargepilot
22

3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.ServiceConnection
37
import android.os.Bundle
8+
import android.os.IBinder
49
import android.widget.TextView
510
import androidx.appcompat.app.AlertDialog
611
import androidx.appcompat.app.AppCompatActivity
@@ -10,80 +15,84 @@ import com.evsuite.hardware.EVHardware
1015
import com.evsuite.hardware.FirmwareInfo
1116
import com.evsuite.hardware.diag.CrashLogger
1217
import com.evsuite.hardware.telemetry.EnergySnapshot
13-
import com.evsuite.hardware.telemetry.EnergyTelemetryReader
14-
import com.evsuite.hardware.telemetry.EnergyTripHistoryStore
1518
import com.evsuite.hardware.telemetry.EnergyTripSession
1619
import com.evsuite.hardware.telemetry.Provenanced
17-
import java.io.File
1820
import java.util.Locale
1921
import java.util.concurrent.Executors
20-
import java.util.concurrent.ScheduledFuture
21-
import java.util.concurrent.TimeUnit
2222

2323
class MainActivity : AppCompatActivity() {
2424

2525
private lateinit var binding: ActivityMainBinding
26-
private lateinit var reader: EnergyTelemetryReader
27-
private lateinit var tripStore: EnergyTripHistoryStore
2826
private lateinit var provenance: ProvenanceText
29-
private val sampler = Executors.newSingleThreadScheduledExecutor { runnable ->
30-
Thread(runnable, "chargepilot-sampler")
27+
/** Ten binder reads and a disk read for the diagnostics report; not the main thread's work. */
28+
private val background = Executors.newSingleThreadExecutor { runnable ->
29+
Thread(runnable, "chargepilot-diagnostics")
3130
}
32-
private var samplingTask: ScheduledFuture<*>? = null
33-
/** Written by the sampler thread, read by the main thread on a control tap. */
34-
@Volatile private var latest: EnergySnapshot? = null
31+
32+
/** The recorder owns the sampler; this screen is one of its readers. */
33+
private var recorder: TripRecordingService? = null
3534
/** The last frame drawn, so the diagnostics report can explain what the screen shows. */
3635
@Volatile private var latestReadings: DashboardReadings = DashboardReadings.empty()
3736

37+
private val connection = object : ServiceConnection {
38+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
39+
val bound = (service as? TripRecordingService.LocalBinder)?.service ?: return
40+
recorder = bound
41+
bound.setListener(::render)
42+
bound.latest?.let(::render)
43+
}
44+
45+
override fun onServiceDisconnected(name: ComponentName?) {
46+
recorder = null
47+
}
48+
}
49+
3850
override fun onCreate(savedInstanceState: Bundle?) {
3951
super.onCreate(savedInstanceState)
4052
binding = ActivityMainBinding.inflate(layoutInflater)
4153
setContentView(binding.root)
4254

4355
provenance = ProvenanceText(this)
44-
reader = EnergyTelemetryReader(applicationContext)
45-
tripStore = EnergyTripHistoryStore(File(filesDir, "trips.json"))
4656
binding.tripAction.setOnClickListener { toggleTrip() }
4757
binding.diagnosticsAction.setOnClickListener { showDiagnostics() }
4858
renderUnavailable()
4959
}
5060

61+
/**
62+
* Binding is what makes the recorder sample for this screen; it keeps sampling after the
63+
* unbind only when a trip is actually being recorded.
64+
*/
5165
override fun onStart() {
5266
super.onStart()
53-
samplingTask = sampler.scheduleWithFixedDelay(::sample, 0L, 1L, TimeUnit.SECONDS)
67+
bindService(
68+
Intent(this, TripRecordingService::class.java), connection, Context.BIND_AUTO_CREATE
69+
)
5470
}
5571

5672
override fun onStop() {
57-
samplingTask?.cancel(false)
58-
samplingTask = null
73+
recorder?.setListener(null)
74+
recorder = null
75+
unbindService(connection)
5976
super.onStop()
6077
}
6178

6279
override fun onDestroy() {
63-
sampler.shutdownNow()
80+
background.shutdownNow()
6481
super.onDestroy()
6582
}
6683

67-
private fun sample() {
68-
val value = runCatching { reader.read() }
69-
.onFailure { AppLogger.w(TAG, "telemetry sample failed: ${it.message}") }
70-
.getOrNull() ?: return
71-
latest = value
72-
EnergyTripSession.add(value)
73-
runOnUiThread { render(value) }
74-
}
75-
7684
private fun toggleTrip() {
77-
val value = latest ?: return
85+
val service = recorder
7886
if (EnergyTripSession.isRecording) {
79-
val summary = EnergyTripSession.stop(value.timestampMs) ?: return
80-
if (!tripStore.append(summary)) {
81-
AppLogger.w(TAG, "trip history could not be saved")
87+
if (service == null) {
88+
AppLogger.w(TAG, "trip stop ignored: recorder not bound")
89+
return
8290
}
91+
service.stopTrip()
92+
service.latest?.let(::render)
8393
} else {
84-
EnergyTripSession.start(value)
94+
TripRecordingService.start(this)
8595
}
86-
render(value)
8796
}
8897

8998
private fun render(value: EnergySnapshot) {
@@ -165,7 +174,7 @@ class MainActivity : AppCompatActivity() {
165174
* built on the sampler thread and only the dialog itself touches the main one.
166175
*/
167176
private fun showDiagnostics() {
168-
sampler.execute {
177+
background.execute {
169178
val body = diagnosticsReport()
170179
runOnUiThread {
171180
if (isFinishing || isDestroyed) return@runOnUiThread

0 commit comments

Comments
 (0)