Skip to content

Commit 3f7b650

Browse files
malysclaude
andcommitted
feat(validation): arm the unstable channel by itself, and ask who would take a destination
Two bundles, two drives, `validationModeOn=false` in both. The toggle is three taps from the dashboard and a driver about to leave has no reason to walk that path, so the eleven questions an unstable build exists to answer stayed open. It now arms itself; turning it off is the driver's decision. Stable compiles none of this. Q9 asks the cheap half of the handoff question. The app plans a route and the driver retypes it into MG4 Navigator, and if the plan was "via Béziers" they have to remember the waypoint too — otherwise the car takes a different road and the arrival charge on screen is about a trip nobody is making. Whether the destination can be handed over instead starts with who, if anyone, answers a navigation intent. Resolving one costs no drive, no network and no request, so it is read at process start. Nothing is sent, and the coordinates are Notre-Dame rather than the driver's next destination: the answer leaves the car on a USB stick. Bumps EVHardware for the range-estimate and trip-store fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e0c652a commit 3f7b650

4 files changed

Lines changed: 78 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **The unstable build arms its own validation.** Two drives came back with
10+
`validationModeOn=false` — eleven questions still open, because the toggle sits three taps
11+
from the dashboard and a driver about to leave has no reason to go looking for it. An unstable
12+
build exists to answer those questions, so it now arms itself and the driver's decision is the
13+
one that turns it *off*. The stable channel is unchanged: none of this is compiled into it.
14+
15+
- **Q9: does anything on this head unit accept a destination?** The route this app plans is
16+
copied into MG4 Navigator by hand, which is where the feature gets abandoned and, worse, where
17+
it gets silently wrong — a plan the car is not following still displays an arrival charge. The
18+
probe resolves the three shapes of Android navigation intent and records which packages claim
19+
them. It sends nothing, needs no drive and no network, and it aims at a public landmark in
20+
another city rather than anywhere the driver has been, because the answer leaves the car on a
21+
USB stick.
22+
923
- **The destination probe now fires on the firmware it was written for.** CP-051's question —
1024
do IMapService transactions 38 and 39 carry the destination — is worth asking at exactly one
1125
moment: while the car is guiding to somewhere. That moment was detected by counting guidance

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ enum class ValidationQuestion(
6262
"Does the route what-if produce rows on this firmware, or a refusal?",
6363
"a route comes back and the what-if is computed from it",
6464
),
65+
NAVIGATION_HANDOFF(
66+
"Q9",
67+
"CP-056",
68+
"Does anything on this head unit answer a navigation intent, so a chosen route " +
69+
"could be handed to the car's own navigation instead of copied by hand?",
70+
"the application starts; the intents are resolved, never sent",
71+
),
6572
LOCATION_FALLBACK(
6673
"Q8",
6774
"CP-046",

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package com.evsuite.chargepilot
22

33
import android.Manifest
44
import android.content.Context
5+
import android.content.Intent
56
import android.content.pm.PackageManager
7+
import android.net.Uri
68
import androidx.core.content.ContextCompat
79
import com.evsuite.hardware.FirmwareInfo
810
import com.evsuite.hardware.saic.SaicNav
@@ -20,8 +22,16 @@ import java.util.concurrent.Executors
2022
* than from a screen, and they all land in the diagnostic bundle the export already writes.
2123
*
2224
* **One toggle.** The only question a driver should have to ask on returning is "did I turn it
23-
* on", and there is exactly one thing to have turned on. It is off by default and persists, so
24-
* turning it on before a drive covers the whole drive including the process restarts in it.
25+
* on", and there is exactly one thing to have turned on. It persists, so setting it before a
26+
* drive covers the whole drive including the process restarts in it.
27+
*
28+
* **Armed by default on this channel.** The bundles of 2026-09-04 and 2026-09-05 both came back
29+
* with `validationModeOn=false`: two drives spent, eleven questions still open, because the
30+
* toggle is on a screen three taps from the dashboard and a driver about to leave has no reason
31+
* to go looking for it. An unstable build exists to answer those questions, so it now arms
32+
* itself and the driver's decision is the one that turns it *off*. A build that ships to a
33+
* driver who did not ask for this is the stable one, where [IS_SUPPORTED] is false and none of
34+
* this is compiled in.
2535
*
2636
* **Cost when off.** [record] returns before its lambda is called, so a disarmed unstable
2737
* build does not even build the strings; nothing binds, nothing polls, nothing is written.
@@ -32,6 +42,9 @@ object ValidationProbe {
3242
private const val PREFERENCES = "chargepilot_validation"
3343
private const val KEY_ENABLED = "enabled"
3444

45+
/** Armed unless the driver said otherwise; see the note on the channel above. */
46+
private const val ENABLED_BY_DEFAULT = true
47+
3548
/** Enough lines for every route request of a long drive, and bounded against a loop. */
3649
private const val LINES_PER_QUESTION = 24
3750

@@ -75,7 +88,7 @@ object ValidationProbe {
7588
val app = context.applicationContext
7689
appContext = app
7790
enabled = app.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
78-
.getBoolean(KEY_ENABLED, false)
91+
.getBoolean(KEY_ENABLED, ENABLED_BY_DEFAULT)
7992
if (!enabled || armedAtMs != null) return
8093
armedAtMs = System.currentTimeMillis()
8194
record(ValidationQuestion.LOCATION_GRANT) {
@@ -84,9 +97,37 @@ object ValidationProbe {
8497
"coarse=${granted(app, Manifest.permission.ACCESS_COARSE_LOCATION)}, " +
8598
"firmware=${FirmwareInfo.getGeneration().name}"
8699
}
100+
recordNavigationHandoff(app)
87101
probeDestinationLater(app, "at process start, before any guidance was heard")
88102
}
89103

104+
/**
105+
* Who, if anyone, would accept a destination from this app.
106+
*
107+
* The route this app plans is currently copied into the car's navigation by hand, which is
108+
* the part of the feature a driver about to leave will skip. Whether it can be handed over
109+
* instead has one cheap half and one expensive half, and this is the cheap half: resolving
110+
* an intent costs no drive, no network and no destination, so it is read at process start.
111+
*
112+
* Nothing is sent. `queryIntentActivities` asks the package manager who *would* answer, and
113+
* the coordinates are a public landmark in another city rather than anywhere the driver has
114+
* been. Package visibility filtering starts at API 30 and this head unit is API 28, so an
115+
* empty list here means nothing is registered rather than that we were not allowed to look.
116+
*/
117+
private fun recordNavigationHandoff(context: Context) {
118+
val manager = context.packageManager
119+
NAVIGATION_INTENTS.forEach { uri ->
120+
val handlers = runCatching {
121+
manager.queryIntentActivities(Intent(Intent.ACTION_VIEW, Uri.parse(uri)), 0)
122+
.map { it.activityInfo.packageName }
123+
.distinct()
124+
}.getOrElse { listOf("query failed: ${it.javaClass.simpleName}") }
125+
record(ValidationQuestion.NAVIGATION_HANDOFF) {
126+
"$uri -> ${handlers.ifEmpty { listOf("nothing") }.joinToString(", ")}"
127+
}
128+
}
129+
}
130+
90131
/** The one toggle. Turning it on arms everything; turning it off keeps what was recorded. */
91132
fun setEnabled(context: Context, value: Boolean) {
92133
val app = context.applicationContext
@@ -173,6 +214,18 @@ object ValidationProbe {
173214
}
174215
}
175216

217+
/**
218+
* The three shapes an Android navigation handoff takes, aimed at a public landmark.
219+
*
220+
* Notre-Dame de Paris, because a probe that resolves an intent for the driver's own next
221+
* destination would put that destination in a file that leaves the car on a USB stick.
222+
*/
223+
private val NAVIGATION_INTENTS = listOf(
224+
"geo:48.8530,2.3499",
225+
"geo:0,0?q=48.8530,2.3499(Charge stop)",
226+
"google.navigation:q=48.8530,2.3499",
227+
)
228+
176229
private fun granted(context: Context, permission: String): Boolean =
177230
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
178231
}

0 commit comments

Comments
 (0)