Skip to content

Commit db195bf

Browse files
malysclaude
andcommitted
feat(settings): the routing key goes back onto the stick it came from
Configuring this car meant a key on a USB stick, because typing sixty characters on this head unit is not something anyone does twice — and there was no way back out. A second car, a factory reset, or the unstable channel, which is a separate application id with its own preferences, meant typing it again. Export to USB writes the same `key = value` file Import from USB reads, under one fixed name so a stale export cannot be picked up by a later import, and to a removable volume only: the keys are not copied into a second unencrypted place on the car. This is the first time the key leaves the device. It is deliberate and it is contained — parked only, driver-initiated, removable media only — and it is said out loud in both directions: the file carries the warning in its own header, and the screen says the stick now holds the keys in clear text. RoutingCredentials.snapshot is the single way out, and its doc says so where the old "the key never leaves this file" claim used to sit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7c25e8e commit db195bf

9 files changed

Lines changed: 212 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **The routing key goes back onto the stick it came from.** Configuring this car meant a key on
10+
a USB stick, because typing sixty characters on this head unit is not something anyone does
11+
twice — and there was no way back out, so a second car, a factory reset or the unstable
12+
channel, which is a separate app with its own storage, meant typing it anyway. *Export to USB*
13+
writes the same `key = value` file *Import from USB* reads, under one fixed name so a stale
14+
export cannot be picked up later, and to a removable volume only: the keys are not copied into
15+
a second unencrypted place on the car. The file carries them in clear text, which is what a
16+
file this app can import has to be — so the file says so in its own header, and the screen says
17+
so when the export lands. Parked only, like everything else on that screen.
18+
919
- **One drive answers eight questions, on the unstable channel only.** Eleven tickets ended
1020
"verified on a laptop, never on the car", and each named a different question; answered one
1121
at a time they would cost eight evenings. There is now one toggle on the evidence screen

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ and no query. The default is HeiGIT's own host: `api.openrouteservice.org` was d
166166
config file still naming it will stop working. The key is stored encrypted through the Android keystore, is never displayed
167167
again, never written to a log, and never included in a diagnostic export.
168168

169+
**Export to USB** writes that same file back out, as `evchargepilot-routing.txt` on the first
170+
writable USB stick — for a second car, after a factory reset, or for the unstable channel, which
171+
keeps its own separate configuration. It is the one way the key leaves the car, it happens only
172+
when you ask for it while parked, and the file holds your keys in clear text: from then on the
173+
stick is the secret. The file says so in its own header.
174+
169175
Free-tier allowances, as published by ORS: 2000 directions requests a day and 40 in any rolling
170176
60 seconds, counted from your first request rather than from midnight. The app counts its own
171177
requests and refuses before the server does, and every request follows a driver action — nothing

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.os.IBinder
99
import androidx.appcompat.app.AppCompatActivity
1010
import com.evsuite.chargepilot.databinding.ActivityRoutingSettingsBinding
1111
import com.evsuite.chargepilot.route.RoutingConfig
12+
import com.evsuite.chargepilot.route.RoutingConfigExport
1213
import com.evsuite.chargepilot.route.RoutingConfigImport
1314
import com.evsuite.chargepilot.route.RoutingCredentials
1415
import java.util.concurrent.Executors
@@ -24,7 +25,8 @@ import java.util.concurrent.Executors
2425
* **The key is never displayed back.** The field starts empty even when a key is stored, the
2526
* status line says configured or not and never shows a value, and nothing here reaches
2627
* `AppLogger` or the diagnostic export. A screen that can show a secret is a screen that shows
27-
* it to a passenger with a phone camera.
28+
* it to a passenger with a phone camera. The one copy that leaves is the export, which the
29+
* driver asks for by hand and which lands on their own stick, never on this screen.
2830
*
2931
* Parked-only, like every other driver action in this app: entering a key means a keyboard.
3032
* Clearing one does not, which is why it stays available — a driver who wants their key off the
@@ -77,6 +79,7 @@ class RoutingSettingsActivity : AppCompatActivity() {
7779
binding.backAction.setOnClickListener { finish() }
7880
binding.routingSaveAction.setOnClickListener { save() }
7981
binding.routingImportAction.setOnClickListener { import() }
82+
binding.routingExportAction.setOnClickListener { export() }
8083
binding.routingClearAction.setOnClickListener { clear() }
8184
binding.routingBaseUrlInput.setText(RoutingCredentials.baseUrl(this))
8285
render()
@@ -164,6 +167,37 @@ class RoutingSettingsActivity : AppCompatActivity() {
164167
}
165168
}
166169

170+
/**
171+
* The same file back out, so a second car — or the unstable channel, which is a separate
172+
* application id with its own preferences — does not mean typing the key again.
173+
*
174+
* A removable volume only: writing the keys into this app's private folder would be a second
175+
* unencrypted copy nobody asked for and nobody could reach. The stick then carries the keys
176+
* in clear text, which is what the announcement says.
177+
*/
178+
private fun export() {
179+
val config = RoutingCredentials.snapshot(this)
180+
if (config.isEmpty()) {
181+
announce(getString(R.string.routing_export_empty))
182+
return
183+
}
184+
val roots = DiagnosticUsbStorage.roots(this)
185+
disk.execute {
186+
val written = roots.firstNotNullOfOrNull { root ->
187+
DiagnosticUsbStorage.writableTarget(this, root)
188+
?.let { RoutingConfigExport.write(it, config) }
189+
}
190+
runOnUiThread {
191+
if (isFinishing || isDestroyed) return@runOnUiThread
192+
if (written == null) {
193+
announce(getString(R.string.routing_export_failed))
194+
} else {
195+
announce(getString(R.string.routing_export_done, written.name))
196+
}
197+
}
198+
}
199+
}
200+
167201
private fun clear() {
168202
RoutingCredentials.clear(this)
169203
binding.routingKeyInput.text?.clear()
@@ -191,6 +225,7 @@ class RoutingSettingsActivity : AppCompatActivity() {
191225
binding.routingBaseUrlLayout.isEnabled = editable
192226
binding.routingSaveAction.isEnabled = editable
193227
binding.routingImportAction.isEnabled = editable
228+
binding.routingExportAction.isEnabled = editable
194229

195230
binding.routingStatus.text = message ?: when (gate) {
196231
ParkedDeletionGate.MOVING -> getString(R.string.routing_moving)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.evsuite.chargepilot.route
2+
3+
import java.io.File
4+
import java.io.FileOutputStream
5+
import java.io.IOException
6+
7+
/**
8+
* Writing the configuration back out to a USB stick, the exact file [RoutingConfigImport] reads.
9+
*
10+
* The driver configured this car from a stick; a second car, a factory reset or the unstable
11+
* channel — a separate application id with its own preferences — means configuring it again, and
12+
* this head unit's keyboard makes retyping a 60-character key an unreasonable thing to ask twice.
13+
*
14+
* **This file carries the keys in clear text**, because that is what a file the app can import
15+
* has to be. The stick is now the secret: the header says so in the file itself, and the driver
16+
* is told so on screen. Nothing else about the car goes in — no trips, no positions, no
17+
* diagnostics, only the four values the settings screen already owns.
18+
*/
19+
object RoutingConfigExport {
20+
21+
/** One fixed name, overwritten: several dated exports and an import picks the oldest one. */
22+
const val FILE_NAME = "evchargepilot-routing.txt"
23+
24+
/**
25+
* `key = value` in the spelling [RoutingConfig.parse] reads first, with a comment header a
26+
* driver reading the stick on a laptop cannot miss. Absent values are left out rather than
27+
* written empty, so importing this file back cannot blank a key it never carried.
28+
*/
29+
fun format(config: RoutingConfig): String = buildString {
30+
appendLine("# EVChargePilot routing configuration.")
31+
appendLine("# It contains your API keys in clear text: this stick is now a secret.")
32+
appendLine("# Import it with 'Import from USB' on the routing key screen.")
33+
config.apiKey?.let { appendLine("ors_api_key = $it") }
34+
config.baseUrl?.let { appendLine("ors_base_url = $it") }
35+
config.chargerApiKey?.let { appendLine("ocm_api_key = $it") }
36+
config.chargerBaseUrl?.let { appendLine("ocm_base_url = $it") }
37+
}
38+
39+
/**
40+
* Writes [config] into [directory], replacing an earlier export. Temp file then rename, so a
41+
* stick pulled mid-write leaves either the old configuration or the new one, never half a key.
42+
*/
43+
fun write(directory: File, config: RoutingConfig): File? {
44+
if (!directory.isDirectory || config.isEmpty()) return null
45+
val target = File(directory, FILE_NAME)
46+
val temp = File(directory, ".$FILE_NAME.${System.nanoTime()}.tmp")
47+
return try {
48+
FileOutputStream(temp).use { output ->
49+
output.write(format(config).toByteArray(Charsets.UTF_8))
50+
output.flush()
51+
output.fd.sync()
52+
}
53+
// FAT has no atomic replace: the old file goes before the new one takes its name.
54+
target.delete()
55+
if (temp.renameTo(target)) target else null
56+
} catch (_: IOException) {
57+
null
58+
} catch (_: SecurityException) {
59+
null
60+
} finally {
61+
if (temp.exists()) temp.delete()
62+
}
63+
}
64+
}

‎app/src/main/java/com/evsuite/chargepilot/route/RoutingCredentials.kt‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import com.evsuite.hardware.AppLogger
1414
* matters more than the encryption: a head unit with a broken keystore must end up with a
1515
* working app, not a crash in a car.
1616
*
17-
* **The key never leaves this file.** Not into `AppLogger`, not into a diagnostic export, not
18-
* into the USB bundle. `DiagnosticExporter` reads nothing from here and must keep not reading
19-
* anything from here.
17+
* **The key never leaves this file on its own.** Not into `AppLogger`, not into a diagnostic
18+
* export, not into the USB bundle. `DiagnosticExporter` reads nothing from here and must keep
19+
* not reading anything from here. The single way out is [snapshot], and only a driver who is
20+
* parked and taps *Export to USB* asks for it.
2021
*/
2122
object RoutingCredentials {
2223

@@ -88,6 +89,23 @@ object RoutingCredentials {
8889
return Values(key, base, OpenChargeMap.HEADER)
8990
}
9091

92+
/**
93+
* Everything stored, for the driver's own export and nothing else. This is the one call that
94+
* hands the keys to a caller that is not building a request: it must not be logged, shown or
95+
* put in a diagnostic bundle. Unset values stay null, so exporting and importing back is not
96+
* a way to write defaults over a self-hosted address.
97+
*/
98+
fun snapshot(context: Context): RoutingConfig {
99+
val prefs = preferences(context)
100+
fun value(key: String): String? = prefs.getString(key, null)?.trim()?.ifEmpty { null }
101+
return RoutingConfig(
102+
apiKey = value(KEY_API_KEY),
103+
baseUrl = value(KEY_BASE_URL),
104+
chargerApiKey = value(KEY_CHARGER_API_KEY),
105+
chargerBaseUrl = value(KEY_CHARGER_BASE_URL),
106+
)
107+
}
108+
91109
fun isConfigured(context: Context): Boolean = read(context) != null
92110

93111
fun isChargerConfigured(context: Context): Boolean = readCharger(context) != null

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@
146146
android:layout_weight="1"
147147
android:text="@string/routing_action_import" />
148148

149+
<com.google.android.material.button.MaterialButton
150+
android:id="@+id/routingExportAction"
151+
style="@style/Widget.EV.Button.Outlined"
152+
android:layout_width="0dp"
153+
android:layout_height="@dimen/touch_target"
154+
android:layout_marginStart="@dimen/spacing_md"
155+
android:layout_weight="1"
156+
android:text="@string/routing_action_export" />
157+
149158
<com.google.android.material.button.MaterialButton
150159
android:id="@+id/routingClearAction"
151160
style="@style/Widget.EVChargePilot.Button.Danger"

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Consommation %6$s %% par km</string>
287287
<string name="routing_helper_base_url">Laissez tel quel pour le service hébergé par HeiGIT, ou pointez vers votre propre instance.</string>
288288
<string name="routing_action_save">Enregistrer</string>
289289
<string name="routing_action_import">Importer depuis USB</string>
290+
<string name="routing_action_export">Exporter vers USB</string>
290291
<string name="routing_action_clear">Supprimer</string>
291292
<string name="routing_configured">Configuré · %1$s</string>
292293
<string name="routing_absent">Non configuré. Saisissez une clé ou importez-la depuis une clé USB.</string>
@@ -298,6 +299,9 @@ Consommation %6$s %% par km</string>
298299
<string name="routing_base_url_refused">Adresse refusée : %1$s. Elle doit être en https, sans identifiants ni paramètres.</string>
299300
<string name="routing_import_none">Aucun fichier de configuration trouvé. Placez sur une clé USB un fichier contenant la ligne ors_api_key = votre-clé.</string>
300301
<string name="routing_import_done">Importé depuis %1$s.</string>
302+
<string name="routing_export_empty">Rien à exporter pour l’instant. Saisissez d’abord une clé.</string>
303+
<string name="routing_export_failed">Aucune clé USB accessible en écriture. Insérez-en une et réessayez.</string>
304+
<string name="routing_export_done">Exporté vers %1$s sur la clé USB. Ce fichier contient vos clés d’API en clair : gardez la clé USB comme vous garderiez ces clés.</string>
301305
<string name="routing_privacy">Seuls la destination saisie, la position du véhicule et le profil de la route sortent de la voiture. Aucun trajet, aucune charge, aucune vitesse, aucun rapport de diagnostic ni identifiant n’est transmis. La clé est stockée chiffrée, n’est jamais journalisée et n’apparaît dans aucun export de diagnostic.</string>
302306
<string name="routing_refused_busy">Une requête est déjà en cours. Attendez sa réponse.</string>
303307
<string name="routing_refused_quota_minute">Trop de requêtes en peu de temps. Réessayez dans %1$s s.</string>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ Spending %6$s %% per km</string>
292292
<string name="routing_helper_base_url">Leave as it is for HeiGIT’s hosted service, or point it at your own instance.</string>
293293
<string name="routing_action_save">Save</string>
294294
<string name="routing_action_import">Import from USB</string>
295+
<string name="routing_action_export">Export to USB</string>
295296
<string name="routing_action_clear">Remove</string>
296297
<string name="routing_configured">Configured · %1$s</string>
297298
<string name="routing_absent">Not configured. Enter a key, or import one from a USB stick.</string>
@@ -303,6 +304,9 @@ Spending %6$s %% per km</string>
303304
<string name="routing_base_url_refused">Address refused: %1$s. It must be https, with no credentials and no query.</string>
304305
<string name="routing_import_none">No configuration file found. Put one on a USB stick with a line reading ors_api_key = your-key.</string>
305306
<string name="routing_import_done">Imported from %1$s.</string>
307+
<string name="routing_export_empty">Nothing to export yet. Enter a key first.</string>
308+
<string name="routing_export_failed">No USB stick that can be written to. Insert one and try again.</string>
309+
<string name="routing_export_done">Exported to %1$s on the USB stick. That file holds your keys in clear text: keep the stick as you would keep them.</string>
306310
<string name="routing_privacy">Only the destination you type, the car’s position and the road profile ever leave the car. No trip, charge, speed, diagnostic bundle or identifier is sent anywhere. The key is stored encrypted, never written to a log and never included in a diagnostic export.</string>
307311
<string name="routing_refused_busy">A request is already in flight. Wait for it.</string>
308312
<string name="routing_refused_quota_minute">Too many requests just now. Try again in %1$s s.</string>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.evsuite.chargepilot.route
2+
3+
import java.io.File
4+
import java.nio.file.Files
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertNull
7+
import org.junit.Test
8+
9+
class RoutingConfigExportTest {
10+
11+
private fun stick(): File = Files.createTempDirectory("routing-export").toFile()
12+
13+
@Test
14+
fun `an exported file is a file this app can import back`() {
15+
val config = RoutingConfig(
16+
apiKey = "5b3ce3597851110001cf6248abc",
17+
baseUrl = "https://ors.example.org",
18+
chargerApiKey = "0a1b2c3d",
19+
chargerBaseUrl = "https://ocm.example.org",
20+
)
21+
val directory = stick()
22+
23+
val written = RoutingConfigExport.write(directory, config)
24+
25+
assertEquals(RoutingConfigExport.FILE_NAME, written?.name)
26+
val found = RoutingConfigImport.search(listOf(directory))
27+
assertEquals(config, found?.config)
28+
}
29+
30+
@Test
31+
fun `values that were never set stay out, so importing back blanks nothing`() {
32+
val text = RoutingConfigExport.format(RoutingConfig(apiKey = "abc"))
33+
34+
assertEquals("abc", RoutingConfig.parse(text).apiKey)
35+
assertNull(RoutingConfig.parse(text).baseUrl)
36+
assertNull(RoutingConfig.parse(text).chargerApiKey)
37+
}
38+
39+
@Test
40+
fun `a second export replaces the first, so an import cannot pick a stale key`() {
41+
val directory = stick()
42+
RoutingConfigExport.write(directory, RoutingConfig(apiKey = "old"))
43+
44+
RoutingConfigExport.write(directory, RoutingConfig(apiKey = "new"))
45+
46+
assertEquals(1, directory.listFiles()?.size)
47+
assertEquals("new", RoutingConfigImport.search(listOf(directory))?.config?.apiKey)
48+
}
49+
50+
@Test
51+
fun `an empty configuration writes no file at all`() {
52+
val directory = stick()
53+
54+
assertNull(RoutingConfigExport.write(directory, RoutingConfig()))
55+
56+
assertEquals(0, directory.listFiles()?.size)
57+
}
58+
}

0 commit comments

Comments
 (0)