Skip to content

Commit e52e709

Browse files
malysclaude
andcommitted
feat(route): hand the plan to the car instead of asking the driver to retype it
CP-056, and the rule it lifts. This app read the car and wrote nothing back; it now writes exactly one thing, on a tap, while parked: a `geo:` destination for whichever navigation app the head unit runs. The reason is not convenience. A route that ends on this screen has to be entered again in MG4 Navigator, and the step that gets skipped is the one that made the numbers true — a plan the car is not following still puts an arrival charge in front of the driver. `geo:` carries one point and no waypoints, so sending the destination cannot pin the road. Where the plan has a charging stop, the stop is what goes: it is the leg the forecast is about, and the driver sets the rest after charging. The screen says which one it sent and says the car picks its own road — on its own line, never in the button's label, because a button reads as a promise and this one cannot make it. The bounds are in SECURITY.md, which had also gone stale claiming the app was offline. Parked gate re-read at the tap rather than trusted from the render that drew the button; one method, never the rest of the OEM's command interface; no coordinates in any probe line, because that file leaves the car on a USB stick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3f7b650 commit e52e709

7 files changed

Lines changed: 178 additions & 6 deletions

File tree

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **The plan can go to the car's navigation instead of being retyped.** CP-056. A route worked
10+
out here used to end on this screen: the driver turned to MG4 Navigator and entered it again,
11+
which is where the feature gets abandoned and, worse, where it goes wrong without saying so —
12+
a plan the car is not following still shows an arrival charge. One button, while parked, sends
13+
a `geo:` destination to whatever navigation app answers.
14+
15+
**It sends the charging stop, not the destination, whenever there is one.** `geo:` carries a
16+
single point and no waypoints, so a route planned *through* somewhere cannot be pinned by its
17+
endpoint. The stop is the leg the forecast is about; the driver sets the rest after charging.
18+
The screen says which one went, and says plainly that the car picks its own road — a caveat on
19+
its own line, never inside the button's label, because a button is read as a promise.
20+
21+
**This is the first thing EVChargePilot asks a vehicle system to do rather than tell it**, and
22+
the boundary that used to say "read-only" now says so with its exception written out. Parked
23+
gate re-read at the tap rather than trusted from the render. One method, one tap, one
24+
destination. `stopNav`, `goHome` and `setFastestRoute` are not called anywhere in the tree.
25+
Nothing about where the driver is going reaches a log or the USB bundle.
26+
927
- **The unstable build arms its own validation.** Two drives came back with
1028
`validationModeOn=false` — eleven questions still open, because the toggle sits three taps
1129
from the dashboard and a driver about to leave has no reason to go looking for it. An unstable

‎SECURITY.md‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,38 @@
22

33
## Capability boundary
44

5-
EVChargePilot is offline and read-only. Its manifest declares only the car permissions needed
6-
to read speed, energy, exterior environment, climate and vendor-extension data. It has no
7-
network, location, overlay, installer, boot receiver or vehicle-setting write path.
8-
9-
Trip history and crash reports stay in app-private storage. No telemetry leaves the device.
5+
EVChargePilot reads the car and does not write to it, with one stated exception. Its manifest
6+
declares the car permissions needed to read speed, energy, exterior environment, climate and
7+
vendor-extension data, plus network and location for route planning (CP-043). It has no
8+
overlay, no installer, no boot receiver and no vehicle-setting write path.
9+
10+
Trip history, evidence captures and crash reports stay in app-private storage. Telemetry never
11+
leaves the device. What leaves it for a route is origin, destination and road profile, and — only
12+
when a charging stop is needed — a window of road around that stop. Never a trip, a charge level,
13+
an odometer, a speed, a climate state or an identifier.
14+
15+
### The one write: handing a destination to the car's navigation
16+
17+
`ChargeStopActivity` can send an `ACTION_VIEW` intent with a `geo:` URI, which moves whichever
18+
navigation app the driver uses to a destination this app planned. It is the only thing here that
19+
asks a vehicle system to act rather than to answer, and it exists because the alternative was a
20+
driver retyping a route — the step that gets skipped, and that silently invalidates every figure
21+
on the screen when it is done wrong.
22+
23+
Its bounds, all of which are testable:
24+
25+
- **Parked.** The parked gate is re-read at the tap, not trusted from the render that drew the
26+
button. A navigation screen changing under someone at 110 km/h is the hazard.
27+
- **One tap, one destination.** Nothing hands over a destination on a timer, on a route arriving,
28+
on a drift, or on anything but a press.
29+
- **One method.** `IMapNotificationListener` carries `stopNav`, `goHome` and `setFastestRoute`.
30+
None of them is called anywhere in this tree, and registering on that interface — which would
31+
mean impersonating a navigation provider — is not done.
32+
- **It cannot lie about what it did.** A destination handed over without the route it belongs to
33+
is labelled on screen as exactly that: the car picks its own road, and the plan's figures stop
34+
describing the drive if it picks a different one.
35+
- **No coordinates in any log or probe.** The validation artifact records that a handoff was
36+
tapped and whether anything accepted it. Never where to.
1037

1138
## Reporting
1239

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.ComponentName
55
import android.content.Context
66
import android.content.Intent
77
import android.content.ServiceConnection
8+
import android.net.Uri
89
import android.os.Bundle
910
import android.os.IBinder
1011
import android.view.View
@@ -20,6 +21,7 @@ import com.evsuite.chargepilot.route.RouteWhatIf
2021
import com.evsuite.chargepilot.route.RoutingCredentials
2122
import com.evsuite.chargepilot.route.RoutingTransport
2223
import com.evsuite.hardware.FirmwareInfo
24+
import com.evsuite.hardware.saic.NavigationHandoff
2325
import com.evsuite.hardware.telemetry.ChargeStopPlan
2426
import com.evsuite.hardware.telemetry.EnergySnapshot
2527
import com.evsuite.hardware.telemetry.EnergyTripHistoryStore
@@ -91,6 +93,9 @@ class ChargeStopActivity : AppCompatActivity() {
9193

9294
private var message: String? = null
9395

96+
/** The plan the handoff button would send, set by the render that offered it. */
97+
private var handoff: Handoff? = null
98+
9499
private val gateExpiry = Runnable { render() }
95100

96101
private val locationPermission = registerForActivityResult(
@@ -146,6 +151,7 @@ class ChargeStopActivity : AppCompatActivity() {
146151
startActivity(Intent(this, VehicleSettingsActivity::class.java))
147152
}
148153
binding.searchAction.setOnClickListener { search() }
154+
binding.navigateAction.setOnClickListener { navigate() }
149155
worker.execute { loadRate() }
150156
render()
151157
}
@@ -492,6 +498,9 @@ class ChargeStopActivity : AppCompatActivity() {
492498
binding.chargerPlace.visibility = View.GONE
493499
binding.chargerSource.visibility = View.GONE
494500
binding.routeWhatIf.visibility = View.GONE
501+
binding.navigateAction.visibility = View.GONE
502+
binding.navigateNote.visibility = View.GONE
503+
handoff = null
495504
return
496505
}
497506
binding.chargeStopPlan.visibility = View.VISIBLE
@@ -539,6 +548,7 @@ class ChargeStopActivity : AppCompatActivity() {
539548

540549
renderCharger(plan, charger)
541550
renderWhatIf(whatIf, alternative)
551+
renderHandoff(place, plan, charger)
542552

543553
// ORS routes are OpenStreetMap under ODbL. Showing this is the licence, not a courtesy.
544554
binding.chargeStopAttribution.visibility =
@@ -624,6 +634,77 @@ class ChargeStopActivity : AppCompatActivity() {
624634
* confirmed are on their own line under it, because a dataset is wrong the day it is
625635
* published and the driver is the one who finds out.
626636
*/
637+
/**
638+
* Offers to hand the plan to the car's navigation, and says what that hand-off cannot do.
639+
*
640+
* **The charging stop wins over the destination when there is one.** `geo:` carries a single
641+
* point, so a route planned *through* somewhere cannot be pinned by sending its endpoint —
642+
* the car would pick its own road and every figure on this screen would quietly become a
643+
* figure about a different trip. Sending the stop pins the leg the forecast is actually
644+
* about, and the driver sets the rest after charging. A stop with no charger found has no
645+
* coordinates, so that case falls back to the destination and says so.
646+
*
647+
* The caveat is a separate line, never the button's label. A button is read as a promise and
648+
* this one cannot make it: nothing here can stop the car choosing another road.
649+
*/
650+
private fun renderHandoff(
651+
place: OrsGeocode.Place,
652+
plan: ChargeStopPlan.Plan,
653+
charger: Found?,
654+
) {
655+
val stop = (plan as? ChargeStopPlan.Plan.Stop)?.let { charger }
656+
val target = if (stop != null) {
657+
Handoff(stop.charger.latitude, stop.charger.longitude, stop.charger.name, toStop = true)
658+
} else {
659+
Handoff(place.latitude, place.longitude, place.label, toStop = false)
660+
}
661+
handoff = target
662+
binding.navigateAction.visibility = View.VISIBLE
663+
binding.navigateNote.visibility = View.VISIBLE
664+
binding.navigateNote.setText(
665+
if (target.toStop) R.string.charge_stop_navigate_stop
666+
else R.string.charge_stop_navigate_destination
667+
)
668+
render()
669+
}
670+
671+
/**
672+
* Sends it. The first thing this application asks a vehicle system to do rather than tell it.
673+
*
674+
* Parked, on a tap, once. The gate is re-read here and not trusted from [render]: the button
675+
* was enabled at some earlier moment and the car may have moved since, and a navigation
676+
* screen changing under someone at 110 is the hazard this whole screen is arranged around.
677+
*/
678+
private fun navigate() {
679+
val target = handoff ?: return
680+
if (ParkedDeletionPolicy.gate(speedKmh, speedObservedAtMs, System.currentTimeMillis())
681+
!= ParkedDeletionGate.PARKED
682+
) {
683+
announce(getString(R.string.charge_stop_moving))
684+
return
685+
}
686+
val uri = NavigationHandoff.geoUri(target.latitude, target.longitude, target.label)
687+
?: run {
688+
announce(getString(R.string.charge_stop_navigate_bad_place))
689+
return
690+
}
691+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
692+
// No coordinates in the probe line: this file leaves the car on a USB stick.
693+
ValidationProbe.record(ValidationQuestion.NAVIGATION_HANDOFF) {
694+
"driver tapped the handoff, to=${if (target.toStop) "charging stop" else "destination"}"
695+
}
696+
val sent = runCatching { startActivity(intent) }.isSuccess
697+
ValidationProbe.record(ValidationQuestion.NAVIGATION_HANDOFF) {
698+
if (sent) "accepted by something" else "nothing accepted ACTION_VIEW geo:"
699+
}
700+
announce(
701+
getString(
702+
if (sent) R.string.charge_stop_navigate_sent
703+
else R.string.charge_stop_navigate_none
704+
)
705+
)
706+
}
707+
627708
private fun renderCharger(plan: ChargeStopPlan.Plan, charger: Found?) {
628709
if (plan !is ChargeStopPlan.Plan.Stop) {
629710
binding.chargerPlace.visibility = View.GONE
@@ -710,6 +791,7 @@ class ChargeStopActivity : AppCompatActivity() {
710791
for (index in 0 until binding.destinationResults.childCount) {
711792
binding.destinationResults.getChildAt(index).isEnabled = usable
712793
}
794+
binding.navigateAction.isEnabled = usable
713795

714796
binding.chargeStopStatus.text = message ?: when (gate) {
715797
ParkedDeletionGate.MOVING -> getString(R.string.charge_stop_moving)
@@ -730,6 +812,14 @@ class ChargeStopActivity : AppCompatActivity() {
730812
String.format(Locale.getDefault(), pattern, value)
731813

732814
/** A charger, where it falls on this route, and the charge left on reaching it. */
815+
/** What the button would send, held between the render that offered it and the tap. */
816+
private data class Handoff(
817+
val latitude: Double,
818+
val longitude: Double,
819+
val label: String?,
820+
val toStop: Boolean,
821+
)
822+
733823
private data class Found(
734824
val charger: OpenChargeMap.Charger,
735825
val alongKm: Double,

‎app/src/main/res/layout/activity_charge_stop.xml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,26 @@
172172
android:textAppearance="@style/Text.EV.Body"
173173
android:visibility="gone" />
174174

175+
<!-- CP-056. The action that ends this screen: the plan stops being something the
176+
driver has to copy. Its caveat is a sibling and never the button's own label,
177+
because a driver reads a button as a promise and this one cannot make it. -->
178+
<com.google.android.material.button.MaterialButton
179+
android:id="@+id/navigateAction"
180+
style="@style/Widget.EVChargePilot.Button"
181+
android:layout_width="1400dp"
182+
android:layout_height="@dimen/touch_target"
183+
android:layout_marginTop="@dimen/spacing_lg"
184+
android:text="@string/charge_stop_navigate"
185+
android:visibility="gone" />
186+
187+
<TextView
188+
android:id="@+id/navigateNote"
189+
android:layout_width="1400dp"
190+
android:layout_height="wrap_content"
191+
android:layout_marginTop="@dimen/spacing_sm"
192+
android:textAppearance="@style/Text.EV.Secondary"
193+
android:visibility="gone" />
194+
175195
<TextView
176196
android:id="@+id/chargeStopAttribution"
177197
android:layout_width="1400dp"

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,14 @@ Consommation %6$s %% par km</string>
321321
<string name="charge_stop_charger_none">Aucune borne utilisable sur cette portion. Rechargez avant.</string>
322322
<string name="charge_stop_charger_absent">Ajoutez une clé Open Charge Map pour savoir où vous arrêter.</string>
323323
<string name="charge_stop_charger">%1$s\u2009· à %2$s km · %3$s kW · %4$s</string>
324+
<!-- CP-056. Le passage de relais. La note n’est jamais repliée dans le libellé du bouton :
325+
un conducteur lit un bouton comme une promesse, et « geo: » ne porte qu’un point. -->
326+
<string name="charge_stop_navigate">Y aller</string>
327+
<string name="charge_stop_navigate_stop">Envoie l\u2019arrêt de recharge à la navigation de la voiture. Elle choisit sa route : suivez-la et ce plan décrit le même trajet, prenez-en une autre et les chiffres ci-dessus ne parlent plus de lui. Redonnez la destination finale après la recharge.</string>
328+
<string name="charge_stop_navigate_destination">Envoie la destination à la navigation de la voiture. Elle choisit sa route : les chiffres ci-dessus décrivent ce plan, pas forcément ce que la voiture fera.</string>
329+
<string name="charge_stop_navigate_none">Rien sur cette tête de série n\u2019a accepté de destination. L\u2019itinéraire reste ici ; saisissez-le vous-même dans la navigation de la voiture.</string>
330+
<string name="charge_stop_navigate_sent">Envoyé à la navigation de la voiture.</string>
331+
<string name="charge_stop_navigate_bad_place">Ce lieu n\u2019a pas de coordonnées utilisables : rien n\u2019a été envoyé.</string>
324332
<string name="charge_stop_charger_arrival">Environ %1$s %% restants à l\u2019arrivée sur place.</string>
325333
<string name="charge_stop_charger_source">%1$s · dernière confirmation %2$s. C\u2019est un enregistrement, pas une promesse : la borne peut avoir disparu, être occupée ou en panne.</string>
326334
<string name="charge_stop_charger_source_undated">%1$s · aucune date de confirmation. C\u2019est un enregistrement, pas une promesse : la borne peut avoir disparu, être occupée ou en panne.</string>

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,15 @@ Spending %6$s %% per km</string>
326326
<string name="charge_stop_charger_none">No usable charger found on that stretch of road. Charge before it.</string>
327327
<string name="charge_stop_charger_absent">Add an Open Charge Map key to be told where to stop.</string>
328328
<string name="charge_stop_charger">%1$s\u2009· %2$s km along · %3$s kW · %4$s</string>
329+
<!-- CP-056. The handoff. Its note is never folded into the button label: a driver reads a
330+
button as a promise, and "geo:" carries one point, so a route planned through a charging
331+
stop cannot be pinned by its endpoint. -->
332+
<string name="charge_stop_navigate">Navigate to this</string>
333+
<string name="charge_stop_navigate_stop">Sends the charging stop to the car\u2019s navigation. It picks its own road, so drive it and this plan describes the same trip; go another way and the figures above stop being about it. Set the final destination again after charging.</string>
334+
<string name="charge_stop_navigate_destination">Sends the destination to the car\u2019s navigation. It picks its own road, so the figures above describe this plan, not necessarily what the car will do.</string>
335+
<string name="charge_stop_navigate_none">Nothing on this head unit accepted a destination. The route stays here; enter it in the car\u2019s navigation yourself.</string>
336+
<string name="charge_stop_navigate_sent">Sent to the car\u2019s navigation.</string>
337+
<string name="charge_stop_navigate_bad_place">That place has no usable coordinates, so nothing was sent.</string>
329338
<string name="charge_stop_charger_arrival">About %1$s %% left on arrival there.</string>
330339
<string name="charge_stop_charger_source">%1$s · last confirmed %2$s. This is a record, not a promise: it may be gone, busy or broken.</string>
331340
<string name="charge_stop_charger_source_undated">%1$s · no confirmation date. This is a record, not a promise: it may be gone, busy or broken.</string>

0 commit comments

Comments
 (0)