Skip to content

Commit 12fc55e

Browse files
malysclaude
andcommitted
feat(nav): compose a destination the car's own navigation will take
CP-056. The app plans a route and the driver retypes it into MG4 Navigator; that retyping is where the feature gets abandoned, and where it goes wrong without saying so — a plan the car is not following still puts an arrival charge on screen. This is the first thing in the library that writes towards the car rather than reading from it, so the format is separated from the sending. A `geo:` URI is the platform's own answer and is one line at the call site; the OEM's `startNavFromEVRout` would mean registering on the channel the head unit uses to command the navigation app, which is impersonating a navigation provider to send one destination. Two ways this fails silently, both closed here. Latitude and longitude are two doubles of the same type, and a swap routes to another continent while reading as an ordinary number, so coordinates outside the Earth are refused. And a head unit in French formats 43.5 as `43,5`, where the comma is this URI's field separator — Locale.ROOT throughout. A label comes from a geocoder or a charger dataset, text this project does not control, so it is cut down to letters, digits, spaces and hyphens. Accents stay: a French place name without them is a different name. `geo:` carries no waypoints. That limit belongs on the driver's screen, not behind an abstraction, and the caller owns showing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2c42870 commit 12fc55e

3 files changed

Lines changed: 154 additions & 0 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 to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **`NavigationHandoff` — the first write this library builds towards the car.** Everything else
12+
here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the
13+
car's own navigation instead of retyped, and it is separated from the sending because the
14+
format is the part that can be wrong and the part a JVM test can settle without a head unit.
15+
It refuses coordinates that are not a place on Earth — latitude and longitude are two doubles
16+
of the same type and a swap routes to the wrong continent while looking like an ordinary
17+
number — and it formats in `Locale.ROOT`, because a head unit set to French writes 43.5 as
18+
`43,5` and a comma is a field separator in this URI. A label from a geocoder or a charger
19+
dataset is reduced to letters, digits, spaces and hyphens: it is text this project did not
20+
write, and a parenthesis in it would end the label early. `geo:` carries one point and no
21+
waypoints, which is a limit the caller has to show the driver rather than paper over.
22+
923
### Fixed
1024

1125
- **A momentarily unreadable battery power no longer blanks a settled range estimate.**
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.evsuite.hardware.saic
2+
3+
import java.util.Locale
4+
5+
/**
6+
* A destination, in the one form every Android navigation app has agreed to understand.
7+
*
8+
* **This is the library's first write towards the car.** Everything else here reads: the hub,
9+
* the nav adapter, the guidance getters all answer questions and change nothing. This builds a
10+
* string that will move the screen a driver is about to follow at 130 km/h, and it is separated
11+
* from the sending on purpose — the format is the part that can be wrong, and the part a JVM
12+
* test can prove right without a head unit.
13+
*
14+
* **Why an intent rather than the OEM's own hook.** `IMapNotificationListener` carries
15+
* `startNavFromEVRout`, which is exactly what this is for, but reaching it means registering on
16+
* the channel the head unit uses to *command* the navigation app — impersonating a navigation
17+
* provider to send one destination. `geo:` is the platform's own answer, it is one line, and if
18+
* the head unit answers it there is nothing left to decompile. CP-056's Q9 says which.
19+
*
20+
* **What it cannot do.** `geo:` carries one point. There are no waypoints in it, so a route
21+
* planned *through* somewhere cannot be pinned by handing over its endpoint — the caller hands
22+
* over the charging stop instead, which is the leg the forecast is about, and says so on screen.
23+
*/
24+
object NavigationHandoff {
25+
26+
/**
27+
* The destination as a `geo:` URI, or null when the coordinates are not a place on Earth.
28+
*
29+
* Refusing out-of-range coordinates is not defensive padding: latitude and longitude are two
30+
* doubles of the same type and a swap routes to the wrong continent while looking like an
31+
* ordinary number the whole way. Half the swaps land outside ±90 and this catches those.
32+
*
33+
* [Locale.ROOT] because a head unit set to French formats 43.5 as `43,5`, and a comma is a
34+
* field separator in this URI. The destination would parse, silently, as somewhere else.
35+
*/
36+
fun geoUri(latitude: Double, longitude: Double, label: String? = null): String? {
37+
if (!latitude.isFinite() || !longitude.isFinite()) return null
38+
if (latitude < -90.0 || latitude > 90.0) return null
39+
if (longitude < -180.0 || longitude > 180.0) return null
40+
val point = String.format(Locale.ROOT, "%.6f,%.6f", latitude, longitude)
41+
val name = label?.let(::sanitise)?.takeIf { it.isNotEmpty() }
42+
// The `q=` form is what carries a name; the leading point is what an app that ignores
43+
// `q=` still shows. Both are the same place, so neither reading is wrong.
44+
return if (name == null) "geo:$point?q=$point" else "geo:$point?q=$point($name)"
45+
}
46+
47+
/**
48+
* A label reduced to what cannot change the meaning of the URI around it.
49+
*
50+
* The name comes from a geocoder or a charger dataset — text this project did not write and
51+
* cannot constrain. A parenthesis, an ampersand or a percent in it would end the label early
52+
* or introduce a parameter, so the allowlist is letters, digits, spaces and hyphens, and
53+
* everything else becomes a space. Accented letters are kept: a French place name without
54+
* them is a different name.
55+
*/
56+
private fun sanitise(label: String): String =
57+
label.take(MAX_LABEL_CHARS)
58+
.map { if (it.isLetterOrDigit() || it == ' ' || it == '-') it else ' ' }
59+
.joinToString("")
60+
.trim()
61+
.replace(SPACES, "%20")
62+
63+
/** Long enough for "Ionity Béziers Est", short enough not to be a payload. */
64+
private const val MAX_LABEL_CHARS = 48
65+
66+
private val SPACES = Regex(" +")
67+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.evsuite.hardware.saic
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Test
6+
import java.util.Locale
7+
8+
class NavigationHandoffTest {
9+
10+
@Test fun `a destination becomes a geo uri both readings agree on`() {
11+
assertEquals(
12+
"geo:43.558300,1.533300?q=43.558300,1.533300",
13+
NavigationHandoff.geoUri(43.5583, 1.5333),
14+
)
15+
}
16+
17+
@Test fun `a label travels in the q parameter`() {
18+
assertEquals(
19+
"geo:43.343000,3.215000?q=43.343000,3.215000(Ionity%20Beziers%20Est)",
20+
NavigationHandoff.geoUri(43.343, 3.215, "Ionity Beziers Est"),
21+
)
22+
}
23+
24+
@Test fun `a french locale does not turn the decimal point into a field separator`() {
25+
val previous = Locale.getDefault()
26+
Locale.setDefault(Locale.FRANCE)
27+
try {
28+
assertEquals(
29+
"geo:43.558300,1.533300?q=43.558300,1.533300",
30+
NavigationHandoff.geoUri(43.5583, 1.5333),
31+
)
32+
} finally {
33+
Locale.setDefault(previous)
34+
}
35+
}
36+
37+
@Test fun `a label cannot end itself or add a parameter`() {
38+
assertEquals(
39+
"geo:43.343000,3.215000?q=43.343000,3.215000(Total%20Energies%20A9)",
40+
NavigationHandoff.geoUri(43.343, 3.215, "Total (Energies) &A9"),
41+
)
42+
}
43+
44+
@Test fun `an accented place name keeps its accents`() {
45+
// Carried literally rather than percent-encoded. Android's Uri is lenient about this and
46+
// a French place name stripped of its accents is a different name on a map.
47+
assertEquals(
48+
"geo:43.343000,3.215000?q=43.343000,3.215000(Béziers)",
49+
NavigationHandoff.geoUri(43.343, 3.215, "Béziers"),
50+
)
51+
}
52+
53+
@Test fun `a label of nothing but punctuation is no label at all`() {
54+
assertEquals(
55+
"geo:43.343000,3.215000?q=43.343000,3.215000",
56+
NavigationHandoff.geoUri(43.343, 3.215, "()&"),
57+
)
58+
}
59+
60+
@Test fun `coordinates that are not a place on earth are refused`() {
61+
// The common swap: a French longitude read as a latitude stays in range, but a latitude
62+
// read as a longitude does not always, and neither does anything from a parse failure.
63+
assertNull(NavigationHandoff.geoUri(191.0, 3.0))
64+
assertNull(NavigationHandoff.geoUri(43.0, 210.0))
65+
assertNull(NavigationHandoff.geoUri(Double.NaN, 3.0))
66+
assertNull(NavigationHandoff.geoUri(43.0, Double.POSITIVE_INFINITY))
67+
}
68+
69+
@Test fun `the poles and the meridian are places`() {
70+
assertEquals("geo:-90.000000,-180.000000?q=-90.000000,-180.000000", NavigationHandoff.geoUri(-90.0, -180.0))
71+
assertEquals("geo:0.000000,0.000000?q=0.000000,0.000000", NavigationHandoff.geoUri(0.0, 0.0))
72+
}
73+
}

0 commit comments

Comments
 (0)