Skip to content

Commit 4c75b84

Browse files
malysclaude
andcommitted
feat(validation): notify parked driver when every probe has answered
Once all CP-055 validation questions have recorded at least one line, post one notification (parked only, once per process) asking for a diagnostic export; tap opens Diagnostics. Stable gets a no-op hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0b8ec1b commit 4c75b84

9 files changed

Lines changed: 122 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **"Validation results complete" notice (unstable, CP-055).** Once every validation question
10+
has recorded at least one answer, a notification asks the driver to export the diagnostic to
11+
USB; tapping it opens Diagnostics. Posted once per process, only with the car read as stopped.
912
- **Battery stress while driving (CP-090).** When a trip spent 2 minutes or more pulling hard
1013
(above 60 % of its own peak) at a low charge (under 20 %) or below 0 °C outside, the trip-end
1114
line gains one clause, through the same voice, gate and advice history. The Battery page's

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ class TripRecordingService : Service() {
414414
// CP-087. The daily digest and the parked notification; both cost one comparison on
415415
// every other sample.
416416
batteryAdvisor.onSample(value)
417+
// CP-055: once every validation question has answered, ask the parked driver to export.
418+
EvidenceCaptureHook.onSample(this, ::inAppLanguage, value)
417419
if (automaticDetectionEnabled) {
418420
updateAutomaticMonitorAvailability(value)
419421
val previousState = detector.state

‎app/src/stable/java/com/evsuite/chargepilot/EvidenceCaptureHook.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.evsuite.chargepilot
22

33
import android.app.Activity
44
import android.content.Context
5+
import com.evsuite.hardware.telemetry.EnergySnapshot
56

67
/**
78
* Stable channel: no evidence capture, by construction.
@@ -20,6 +21,9 @@ object EvidenceCaptureHook {
2021
/** Does nothing. Stable arms no probe. */
2122
fun startProbes(context: Context) = Unit
2223

24+
/** Does nothing. Stable has no validation answer to announce. */
25+
fun onSample(context: Context, text: () -> Context, value: EnergySnapshot) = Unit
26+
2327
/** Does nothing. Stable has no probe artifact to write. */
2428
fun saveProbeArtifacts(context: Context) = Unit
2529
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.evsuite.chargepilot
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class ValidationProbeTest {
8+
@Test
9+
fun `ready only once every question has answered`() {
10+
val all = ValidationQuestion.entries.toSet()
11+
assertFalse(ValidationProbe.allAnswered(emptySet()))
12+
assertFalse(ValidationProbe.allAnswered(all - ValidationQuestion.CHARGERS))
13+
assertTrue(ValidationProbe.allAnswered(all))
14+
}
15+
}

‎app/src/unstable/java/com/evsuite/chargepilot/EvidenceCaptureHook.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Activity
44
import android.content.Context
55
import com.evsuite.hardware.FirmwareInfo
66
import com.evsuite.hardware.probe.RuntimeInterfaceProbe
7+
import com.evsuite.hardware.telemetry.EnergySnapshot
78
import java.io.File
89

910
/** Unstable channel: the capture screen exists and is reachable from the dashboard. */
@@ -36,6 +37,10 @@ object EvidenceCaptureHook {
3637
RuntimeInterfaceProbe.connect(context)
3738
}
3839

40+
/** Each trip sample: tells the parked driver once that every validation answer is in. */
41+
fun onSample(context: Context, text: () -> Context, value: EnergySnapshot) =
42+
ValidationReadyNotice.onSample(context, text, value)
43+
3944
/**
4045
* Writes every always-on probe artifact into the folder the diagnostic export bundles.
4146
*

‎app/src/unstable/java/com/evsuite/chargepilot/ValidationProbe.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ object ValidationProbe {
159159
}
160160
}
161161

162+
/** Every question has at least one line: the bundle is worth exporting now. */
163+
fun allAnswered(): Boolean = allAnswered(synchronized(rings) { rings.keys.toSet() })
164+
165+
internal fun allAnswered(answered: Set<ValidationQuestion>): Boolean =
166+
answered.containsAll(ValidationQuestion.entries)
167+
162168
/**
163169
* The car has a destination of its own and is guiding to it — the moment CP-051 asks about.
164170
*
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.app.NotificationChannel
6+
import android.app.NotificationManager
7+
import android.app.PendingIntent
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.content.pm.PackageManager
11+
import android.os.Build
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.NotificationManagerCompat
14+
import androidx.core.content.ContextCompat
15+
import com.evsuite.hardware.AppLogger
16+
import com.evsuite.hardware.telemetry.EnergySnapshot
17+
18+
/**
19+
* Tells the driver once that every validation question has an answer, so the bundle is worth
20+
* exporting now rather than after another drive spent guessing.
21+
*
22+
* Parked only, with the same gate as the battery advice: the questions mostly answer
23+
* themselves on the road, and a notice that pops up at speed is the overlay the suite forbids.
24+
* Once per process — the rings are in memory, so a restart starts the count again anyway.
25+
*/
26+
internal object ValidationReadyNotice {
27+
28+
@Volatile
29+
private var posted = false
30+
31+
/** On the sampler thread, every sample; one flag and one set comparison until it fires. */
32+
fun onSample(context: Context, text: () -> Context, value: EnergySnapshot) {
33+
if (posted || !ValidationProbe.allAnswered()) return
34+
val gate = ParkedDeletionPolicy.gate(value.speedKmh, value.timestampMs, System.currentTimeMillis())
35+
if (gate != ParkedDeletionGate.PARKED) return
36+
posted = true
37+
AppLogger.i(TAG, "validation: every question answered, export suggested")
38+
runCatching { post(context.applicationContext, text()) }
39+
.onFailure { AppLogger.w(TAG, "validation notice failed: ${it.message}") }
40+
}
41+
42+
// API 28 head unit: no permission gates a notification. On API 33+ without
43+
// POST_NOTIFICATIONS (not declared) nothing is posted; the log line above still says it.
44+
@SuppressLint("MissingPermission", "NotificationPermission")
45+
private fun post(context: Context, res: Context) {
46+
val manager = NotificationManagerCompat.from(context)
47+
if (!manager.areNotificationsEnabled()) return
48+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
49+
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
50+
PackageManager.PERMISSION_GRANTED
51+
) return
52+
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(
53+
NotificationChannel(
54+
CHANNEL_ID,
55+
res.getString(R.string.validation_ready_channel),
56+
NotificationManager.IMPORTANCE_DEFAULT,
57+
)
58+
)
59+
val tap = PendingIntent.getActivity(
60+
context, 0,
61+
Intent(context, DiagnosticsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
62+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
63+
)
64+
val body = res.getString(R.string.validation_ready_text)
65+
manager.notify(
66+
NOTIFICATION_ID,
67+
NotificationCompat.Builder(context, CHANNEL_ID)
68+
.setSmallIcon(R.drawable.ic_launcher_monochrome)
69+
.setContentTitle(res.getString(R.string.validation_ready_title))
70+
.setContentText(body)
71+
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
72+
.setContentIntent(tap)
73+
.setAutoCancel(true)
74+
.build(),
75+
)
76+
}
77+
78+
private const val TAG = "EVChargePilot"
79+
private const val CHANNEL_ID = "validation_ready"
80+
private const val NOTIFICATION_ID = 200
81+
}

‎app/src/unstable/res/values-fr/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
<string name="validation_arm">Armer le trajet de validation</string>
3434
<string name="validation_disarm">Désarmer le trajet de validation</string>
3535
<string name="validation_on">Mode validation actif. Chaque question ouverte s’enregistre seule ; exportez vers l’USB au retour.</string>
36+
<string name="validation_ready_channel">Trajet de validation</string>
37+
<string name="validation_ready_title">Résultats de validation complets</string>
38+
<string name="validation_ready_text">Toutes les questions des sondes ont enregistré leur réponse. Exportez le diagnostic vers l’USB maintenant pour ne pas perdre ce trajet.</string>
3639
<string name="validation_off">Mode validation inactif. Rien n’est enregistré tant qu’il n’est pas armé.</string>
3740
<string name="update_title">Version instable disponible</string>
3841
<string name="update_ready">%1$s a été téléchargé dans :\n%2$s\n\nEVChargePilot ne peut pas l’installer. Ouvrez-le depuis le gestionnaire de fichiers du head unit, véhicule à l’arrêt.</string>

‎app/src/unstable/res/values/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
<string name="validation_arm">Arm validation drive</string>
3434
<string name="validation_disarm">Disarm validation drive</string>
3535
<string name="validation_on">Validation mode on. Every open question records itself; export to USB when you are back.</string>
36+
<string name="validation_ready_channel">Validation drive</string>
37+
<string name="validation_ready_title">Validation results complete</string>
38+
<string name="validation_ready_text">Every probe question has recorded its answer. Export the diagnostic to USB now so this drive is not lost.</string>
3639
<string name="validation_off">Validation mode off. Nothing is recorded until it is armed.</string>
3740
<string name="update_title">Unstable build available</string>
3841
<string name="update_ready">%1$s has been downloaded to:\n%2$s\n\nEVChargePilot cannot install it. Open it from the head unit’s file manager when the car is parked.</string>

0 commit comments

Comments
 (0)