diff --git a/Phone App/README.md b/Phone App/README.md
index c8761ba..6f4c396 100644
--- a/Phone App/README.md
+++ b/Phone App/README.md
@@ -115,6 +115,9 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
organization a new account lands in (or none at all); an admin gets no picker,
because the server puts their members in their own organization regardless.
+- **Android Auto** — the garage on the car's own screen, read-only: which car
+ is overdue, and what it has coming. See *In the car* below.
+
Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly`
getters that gate the UI, mirroring the server's access checks.
@@ -196,6 +199,72 @@ Upgrading from a single-server build carries what was there onto the home entry
— the saved session (`cc_token` / `cc_user`) and the address it was pointed at
(`cc_server_url`) — so nobody is signed out by the update.
+## In the car — Android Auto
+
+The same APK is an Android Auto app. Plug the phone in and DriverVault is on the
+head unit: a **garage list**, one row per car with its due badge under the name,
+and a tap opens what that car has coming — the odometer, the next service, and
+the reminders the server holds for it, stored and auto-derived alike.
+
+There is no Flutter engine on a head unit. The car screens are
+[Car App Library](https://developer.android.com/training/cars/apps) templates the
+host draws itself, written in Kotlin under
+`android/app/src/main/kotlin/com/drivervault/phoneapp/car/`, and they take three
+things from the phone app rather than growing their own:
+
+- **The session.** `VaultStore` reads what the phone has already signed in with —
+ the active server's base URL and its token — out of shared_preferences' own
+ store, which both halves of the app share. Switch server on the phone and the
+ car reads the new one; there is nothing to keep in step. A head unit is the
+ last place to type a password, so if nothing is signed in the car says so and
+ points at the phone. The one key added for this is `cc_active_base`, the
+ *resolved* base URL: an untouched home entry carries no address of its own, its
+ base being `kDefaultApiBase` — a compile-time define nothing outside Dart can
+ read.
+- **The words.** `CarStrings` reads the same `assets/i18n/{lang}.json` files, by
+ the same dot-path keys, in the language the account chose. A due badge on the
+ head unit is the string `lib/format.dart` puts on the phone, not a second
+ translation of it. Only four keys are the car's own (`carApp.*`): what to say
+ when nobody is signed in, when the token has been refused, when the server
+ can't be reached, and the Refresh button.
+- **The figures.** `CarFormat` is the twin of `lib/format.dart`: same date format
+ and number grouping from the account's settings, same worst-of-date-and-km
+ service badge, same wording for a reminder's status.
+
+Two things are deliberately unlike the phone:
+
+- **It only reads.** No car is edited, no odometer updated, no reminder marked
+ done. A screen you cannot type into is a poor place to edit a car and a driver
+ is a poor person to ask, so `VaultApi` has no write in it to reach for by
+ accident.
+- **Worst first.** The host shows only the first few rows of any list, so the
+ garage is sorted by badge rather than kept in the phone's order — the car this
+ list exists to mention is the overdue one, not whichever was added first.
+
+One thing to know before turning it on: **the app lock does not reach the head
+unit.** Biometric login keeps the phone's own UI behind a fingerprint (see above),
+but that flag lives in memory and the credentials behind it in encrypted storage,
+neither of which the car service can read — and a head unit has no fingerprint
+reader to satisfy it with anyway. So a phone that is paired to a car shows the
+garage there, read-only, whether or not the phone itself is locked. Everything on
+those screens is maintenance data, and the phone still has to be the one plugged
+in, but it is the one place the lock stops short.
+
+Running it on your own car:
+
+- The service is declared under `androidx.car.app.category.IOT`. It is the
+ closest of the categories the library defines — DriverVault is a garage, not a
+ map, a media player or a parking service — and a Play Store submission would
+ be reviewed against it. This build is sideloaded, so what matters instead is
+ the next line.
+- Android Auto refuses apps it did not get from the Play Store until you tell it
+ otherwise: in the **Android Auto** settings on the phone, tap the version ten
+ times to unlock **Developer settings**, then turn on **Unknown sources**.
+- To try it without a car, run Google's
+ [Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu).
+ A debug build accepts any host so the DHU can connect; a release build only
+ accepts the signed hosts the library ships an allowlist for.
+
## Configure the API endpoint
The app talks to `kDefaultApiBase` (see `lib/config.dart`), default
@@ -252,3 +321,19 @@ lib/
├── servers_sheet.dart # the server picker + the add / edit / sign-in sheet
└── charging_screen.dart settings_screen.dart admin_users_screen.dart
```
+
+The Android Auto half is Kotlin, because the head unit draws its own templates:
+
+```
+android/app/src/main/kotlin/com/drivervault/phoneapp/
+├── MainActivity.kt # the Flutter host (FlutterFragmentActivity, for local_auth)
+└── car/
+ ├── DriverVaultCarAppService.kt # what Android Auto binds to; the root screen + host validator
+ ├── VaultStore.kt # the phone's session, read from shared_preferences' own store
+ ├── VaultApi.kt # the reads the car screens make — no writes exist here
+ ├── CarStrings.kt # t("key") over the bundled assets/i18n files
+ ├── CarFormat.kt # dates, km and the due badges — the twin of lib/format.dart
+ ├── VaultScreen.kt # load / loading / failed-with-a-Refresh, shared by both screens
+ ├── GarageScreen.kt # the cars, worst badge first
+ └── CarDueScreen.kt # one car: odometer, next service, reminders
+```
diff --git a/Phone App/android/app/build.gradle.kts b/Phone App/android/app/build.gradle.kts
index 911dc66..dde9a88 100644
--- a/Phone App/android/app/build.gradle.kts
+++ b/Phone App/android/app/build.gradle.kts
@@ -39,6 +39,14 @@ kotlin {
}
}
+dependencies {
+ // Android Auto. The head unit runs no Flutter engine — the car screens under
+ // src/main/kotlin/com/drivervault/phoneapp/car are Car App Library templates
+ // the host draws itself. The one Android dependency this app has, and it is
+ // never loaded on a phone that isn't plugged into a car.
+ implementation("androidx.car.app:app:1.4.0")
+}
+
flutter {
source = "../.."
}
diff --git a/Phone App/android/app/src/main/AndroidManifest.xml b/Phone App/android/app/src/main/AndroidManifest.xml
index 9ca0cce..272598f 100644
--- a/Phone App/android/app/src/main/AndroidManifest.xml
+++ b/Phone App/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
,
+)
+
+/**
+ * What one car wants doing: its odometer and next service up top, then the
+ * reminders the server holds for it — the ones typed in and the ones it derives
+ * from documents and the service schedule alike, in the order it sorted them,
+ * outstanding first.
+ *
+ * It is the Reminders tab and the service badge of the phone's car page, and
+ * deliberately nothing else: a head unit has no room for a service history, and
+ * the driver has no attention for one.
+ */
+class CarDueScreen(carContext: CarContext, private val car: VaultCar) :
+ VaultScreen(carContext) {
+
+ override val title: String get() = car.name
+
+ override fun read(api: VaultApi): CarDue {
+ val latest = api.latestService(car.id)
+ return CarDue(
+ service = latest,
+ status = format.serviceStatus(latest, car.currentKm),
+ reminders = api.reminders(car.id),
+ )
+ }
+
+ override fun template(data: CarDue): Template {
+ val service = ItemList.Builder()
+ .addItem(
+ Row.Builder()
+ .setTitle(strings.t("dashboard.currentOdometer"))
+ .addText(format.km(car.currentKm))
+ .build(),
+ )
+ .addItem(
+ Row.Builder()
+ .setTitle(strings.t("dashboard.nextDue"))
+ .addText(nextDue(data.service))
+ .addText(tinted(data.status))
+ .build(),
+ )
+ .build()
+
+ // Two rows are spent on the service section, so the reminders get what
+ // the host has left.
+ val room = (listLimit() - 2).coerceAtLeast(1)
+ val reminders = ItemList.Builder()
+ if (data.reminders.isEmpty()) {
+ reminders.addItem(Row.Builder().setTitle(strings.t("car.reminders.empty")).build())
+ } else {
+ for (reminder in data.reminders.take(room)) {
+ reminders.addItem(
+ Row.Builder()
+ .setTitle(reminder.title)
+ .addText(tinted(format.reminderStatus(reminder)))
+ .build(),
+ )
+ }
+ }
+
+ return ListTemplate.Builder()
+ .setTitle(title)
+ .setHeaderAction(headerAction)
+ .setActionStrip(refreshStrip)
+ .addSectionedList(
+ SectionedItemList.create(service, strings.t("dashboard.serviceLife")),
+ )
+ .addSectionedList(
+ SectionedItemList.create(reminders.build(), strings.t("car.reminders.title")),
+ )
+ .build()
+ }
+
+ /** The date and the odometer reading a service falls due on, whichever arrives first. */
+ private fun nextDue(service: VaultService?): String = listOf(
+ format.date(service?.nextServiceDate),
+ format.km(service?.nextServiceKm),
+ ).joinToString(" · ")
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarFormat.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarFormat.kt
new file mode 100644
index 0000000..7aaaf70
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarFormat.kt
@@ -0,0 +1,258 @@
+package com.drivervault.phoneapp.car
+
+import java.text.NumberFormat
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * How urgent a badge is. The four states of lib/format.dart's StatusKey,
+ * declared worst last so the order they are written in is the order they rank.
+ */
+enum class StatusKey {
+ UNKNOWN, OK, SOON, OVERDUE;
+
+ /** How bad this state is beside another one — _rank() in format.dart. */
+ val severity: Int get() = ordinal
+}
+
+/** A badge: how bad it is, and the sentence that says so. */
+data class CarStatus(val key: StatusKey, val label: String)
+
+/**
+ * Dates, distances and the due badges, formatted the way the phone formats them
+ * — the twin of lib/format.dart, reading the same settings (the account's locale
+ * and date format) and, through [CarStrings], the same translated wording. Only
+ * what a car screen shows is here; the fuel and money figures have no head unit
+ * to reach.
+ *
+ * The maths is the phone's too: a service falls due on a date OR an odometer
+ * reading, and the badge takes the worse of the two.
+ */
+class CarFormat(
+ private val strings: CarStrings,
+ localeTag: String,
+ private val dateFormat: String,
+) {
+ private val locale: Locale =
+ Locale.forLanguageTag(localeTag).takeIf { it.language.isNotEmpty() } ?: Locale.US
+
+ /** Grouped by the account's region, so the odometer agrees with the dates beside it. */
+ fun number(value: Int): String = NumberFormat.getIntegerInstance(locale).format(value)
+
+ /** 0 km is a reading — a car collected new — not a blank. */
+ fun km(value: Int?): String = if (value == null) EMPTY else number(value) + " km"
+
+ /** The account's chosen date pattern, with month names in its language. */
+ fun date(iso: String?): String {
+ val day = parse(iso) ?: return EMPTY
+ val pattern = when (dateFormat) {
+ "DMY_NUM" -> "dd-MM-yyyy"
+ "DMY" -> "dd MMM yyyy"
+ "MDY" -> "MMM dd, yyyy"
+ else -> "yyyy-MM-dd"
+ }
+ return SimpleDateFormat(pattern, locale).format(day)
+ }
+
+ /**
+ * The due badge for a car: the worse of its date and odometer signals, worded
+ * the way serviceStatus() words it in format.dart — one headline for the
+ * severity, then each trigger as a bare quantity.
+ */
+ fun serviceStatus(service: VaultService?, currentKm: Int): CarStatus {
+ val date = dateSignal(service?.nextServiceDate)
+ val km = kmSignal(currentKm, service?.nextServiceKm)
+ if (date.key == StatusKey.UNKNOWN && km.key == StatusKey.UNKNOWN) {
+ return CarStatus(StatusKey.UNKNOWN, strings.t("status.noData"))
+ }
+ // With one signal to go on, that signal's own sentence says it best.
+ if (date.key == StatusKey.UNKNOWN) return CarStatus(km.key, km.label)
+ if (km.key == StatusKey.UNKNOWN) return CarStatus(date.key, date.label)
+ val worse = if (km.key.severity > date.key.severity) km else date
+ return CarStatus(worse.key, bothSignals(date, km, worse.key))
+ }
+
+ /**
+ * The badge for one reminder. The server has already picked the worse of its
+ * two triggers; this only chooses the wording.
+ */
+ fun reminderStatus(reminder: VaultReminder): CarStatus {
+ val key = when (reminder.status) {
+ "overdue" -> StatusKey.OVERDUE
+ "due_soon" -> StatusKey.SOON
+ "upcoming" -> StatusKey.OK
+ else -> StatusKey.UNKNOWN // done | no_trigger
+ }
+ if (reminder.status == "done") return CarStatus(key, strings.t("status.done"))
+ if (reminder.status == "no_trigger") return CarStatus(key, strings.t("status.noTrigger"))
+
+ val days = reminder.daysLeft
+ val km = reminder.kmLeft
+ val parts = mutableListOf()
+ if (reminder.status == "overdue") {
+ if (days != null && days < 0) parts += strings.t("status.days", mapOf("days" to -days))
+ if (km != null && km < 0) parts += strings.t("status.km", mapOf("km" to number(-km)))
+ val label = if (parts.isEmpty()) {
+ strings.t("status.overdue")
+ } else {
+ strings.t("status.overdueBy", mapOf("parts" to parts.joinToString(SEPARATOR)))
+ }
+ return CarStatus(key, label)
+ }
+ if (days != null && days >= 0) {
+ parts += if (days == 0) {
+ strings.t("status.today")
+ } else {
+ strings.t("status.days", mapOf("days" to days))
+ }
+ }
+ if (km != null && km >= 0) parts += strings.t("status.km", mapOf("km" to number(km)))
+ val label = if (parts.isEmpty()) {
+ strings.t("status.upcoming")
+ } else {
+ strings.t("status.dueIn", mapOf("parts" to parts.joinToString(SEPARATOR)))
+ }
+ return CarStatus(key, label)
+ }
+
+ // --- the two signals a service is due on ---------------------------------
+
+ private data class Signal(val key: StatusKey, val label: String, val value: Int?)
+
+ private fun dateSignal(iso: String?): Signal {
+ val due = parse(iso) ?: return Signal(StatusKey.UNKNOWN, strings.t("status.noData"), null)
+ val days = daysUntil(due)
+ return when {
+ days < 0 -> Signal(
+ StatusKey.OVERDUE,
+ strings.t("status.serviceOverdueDays", mapOf("days" to -days)),
+ days,
+ )
+ days <= DAYS_SOON -> Signal(
+ StatusKey.SOON,
+ strings.t("status.dueInDays", mapOf("days" to days)),
+ days,
+ )
+ else -> Signal(StatusKey.OK, strings.t("status.okDays", mapOf("days" to days)), days)
+ }
+ }
+
+ private fun kmSignal(currentKm: Int, nextKm: Int?): Signal {
+ if (nextKm == null) return Signal(StatusKey.UNKNOWN, strings.t("status.noKm"), null)
+ val remaining = nextKm - currentKm
+ return when {
+ remaining < 0 -> Signal(
+ StatusKey.OVERDUE,
+ strings.t("status.serviceOverdueKm", mapOf("km" to number(-remaining))),
+ remaining,
+ )
+ remaining <= KM_SOON -> Signal(
+ StatusKey.SOON,
+ strings.t("status.inKm", mapOf("km" to number(remaining))),
+ remaining,
+ )
+ else -> Signal(
+ StatusKey.OK,
+ strings.t("status.kmLeft", mapOf("km" to number(remaining))),
+ remaining,
+ )
+ }
+ }
+
+ /**
+ * Words a badge watching both triggers. Overdue quotes only what has actually
+ * passed: the other trigger is not late, and its comfortable remainder under
+ * an "Overdue" headline would read as one.
+ */
+ private fun bothSignals(date: Signal, km: Signal, key: StatusKey): String {
+ val parts = mutableListOf()
+ if (key == StatusKey.OVERDUE) {
+ if (date.key == StatusKey.OVERDUE) {
+ parts += strings.t("status.days", mapOf("days" to -date.value!!))
+ }
+ if (km.key == StatusKey.OVERDUE) {
+ parts += strings.t("status.km", mapOf("km" to number(-km.value!!)))
+ }
+ return strings.t(
+ "status.serviceOverdueBy",
+ mapOf("parts" to parts.joinToString(SEPARATOR)),
+ )
+ }
+ parts += strings.t("status.days", mapOf("days" to date.value))
+ parts += strings.t("status.km", mapOf("km" to number(km.value!!)))
+ val both = mapOf("parts" to parts.joinToString(SEPARATOR))
+ // Each key spelled out where it is used, rather than picked into a
+ // variable: it is what lets the phone's tests see which keys these
+ // screens ask for.
+ return if (key == StatusKey.SOON) {
+ strings.t("status.dueIn", both)
+ } else {
+ strings.t("status.okIn", both)
+ }
+ }
+
+ // --- dates ---------------------------------------------------------------
+
+ /**
+ * Whole days between today and [due], both taken as local calendar days. The
+ * rounding is what keeps a clock change from turning a 23-hour day into a day
+ * that never elapsed.
+ */
+ private fun daysUntil(due: Date): Int {
+ val target = midnight(due)
+ val today = midnight(Date())
+ return Math.round((target - today) / MILLIS_PER_DAY.toDouble()).toInt()
+ }
+
+ private fun midnight(date: Date): Long {
+ val calendar = Calendar.getInstance()
+ calendar.time = date
+ calendar.set(Calendar.HOUR_OF_DAY, 0)
+ calendar.set(Calendar.MINUTE, 0)
+ calendar.set(Calendar.SECOND, 0)
+ calendar.set(Calendar.MILLISECOND, 0)
+ return calendar.timeInMillis
+ }
+
+ /**
+ * An instant as the API writes it (RFC 3339, out of Go's time.Time), read
+ * into the phone's own time zone the way DateTime.parse().toLocal() reads it
+ * — so the day printed here is the day printed there. A bare date is taken as
+ * a local one, which is what it means.
+ */
+ private fun parse(iso: String?): Date? {
+ val value = iso?.trim().orEmpty()
+ if (!DATE_HEAD.containsMatchIn(value)) return null
+ if (value.length <= 10) return parseWith("yyyy-MM-dd", value, null)
+ // "...T10:30:00.123456789Z" and "...+02:00" -> "...T10:30:00+0000", the
+ // one shape SimpleDateFormat's Z can read.
+ val normalized = value
+ .let { FRACTION.replace(it, "") }
+ .let { if (it.endsWith("Z")) it.dropLast(1) + "+0000" else it }
+ .let { OFFSET_COLON.replace(it, "$1$2") }
+ return parseWith("yyyy-MM-dd'T'HH:mm:ssZ", normalized, null)
+ ?: parseWith("yyyy-MM-dd'T'HH:mm:ss", value.take(19), TimeZone.getTimeZone("UTC"))
+ ?: parseWith("yyyy-MM-dd", value.take(10), null)
+ }
+
+ private fun parseWith(pattern: String, value: String, zone: TimeZone?): Date? = try {
+ SimpleDateFormat(pattern, Locale.US).apply { zone?.let { timeZone = it } }.parse(value)
+ } catch (_: ParseException) {
+ null
+ }
+
+ private companion object {
+ const val EMPTY = "—" // an em dash, the same blank common.empty is
+ const val SEPARATOR = " · "
+ const val DAYS_SOON = 30
+ const val KM_SOON = 1000
+ const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
+ val DATE_HEAD = Regex("^\\d{4}-\\d{2}-\\d{2}")
+ val FRACTION = Regex("\\.\\d+")
+ val OFFSET_COLON = Regex("([+-]\\d{2}):(\\d{2})$")
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarStrings.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarStrings.kt
new file mode 100644
index 0000000..eab198e
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/CarStrings.kt
@@ -0,0 +1,63 @@
+package com.drivervault.phoneapp.car
+
+import android.content.Context
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * The car screens' text, read from the very same per-language files the phone
+ * app reads — assets/i18n/{lang}.json, bundled into the APK under
+ * flutter_assets/ — and looked up by the same dot-path keys. Nothing is retyped
+ * here: a status badge on the head unit is the string lib/format.dart already
+ * puts on the phone, in the language the account chose.
+ *
+ * The one thing t() in i18n.dart does that this does not is plurals: picking a
+ * CLDR category is Intl's job, and no car screen needs one. A plural key comes
+ * back as the key, which is what i18n.dart does with one it cannot render too.
+ */
+class CarStrings private constructor(
+ private val active: JSONObject?,
+ private val base: JSONObject?,
+) {
+ /** Translate [key], interpolating any `{name}` placeholders from [params]. */
+ fun t(key: String, params: Map = emptyMap()): String {
+ val template = lookup(active, key) ?: lookup(base, key) ?: return key
+ return interpolate(template, params)
+ }
+
+ private fun lookup(dict: JSONObject?, key: String): String? {
+ var node: Any? = dict ?: return null
+ for (part in key.split(".")) {
+ node = (node as? JSONObject)?.opt(part) ?: return null
+ }
+ return node as? String
+ }
+
+ private fun interpolate(template: String, params: Map): String =
+ PLACEHOLDER.replace(template) { m ->
+ params[m.groupValues[1]]?.toString() ?: m.value
+ }
+
+ companion object {
+ private const val BASE = "en"
+ private val PLACEHOLDER = Regex("""\{(\w+)}""")
+
+ /** Loads the files for the account's language, falling back to English. */
+ fun of(context: Context): CarStrings {
+ val language = VaultStore.locale(context).substringBefore("-")
+ val base = read(context, BASE)
+ val active = if (language == BASE) null else read(context, language)
+ return CarStrings(active, base)
+ }
+
+ private fun read(context: Context, language: String): JSONObject? = try {
+ context.assets.open("flutter_assets/assets/i18n/$language.json").use {
+ JSONObject(it.readBytes().toString(Charsets.UTF_8))
+ }
+ } catch (_: IOException) {
+ // A language with no file of its own falls back to English, exactly
+ // as it does on the phone.
+ null
+ }
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/DriverVaultCarAppService.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/DriverVaultCarAppService.kt
new file mode 100644
index 0000000..e5471fd
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/DriverVaultCarAppService.kt
@@ -0,0 +1,38 @@
+package com.drivervault.phoneapp.car
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import androidx.car.app.CarAppService
+import androidx.car.app.Screen
+import androidx.car.app.Session
+import androidx.car.app.validation.HostValidator
+
+/**
+ * DriverVault on the car's own screen, over Android Auto.
+ *
+ * The head unit runs no Flutter engine: these screens are the Car App Library's
+ * templates, drawn by the host, reading the session the phone app has already
+ * signed in with (see [VaultStore]) and the translations it already ships (see
+ * [CarStrings]). Nothing is signed into, entered or edited here — the car shows
+ * what is due, and the phone stays where the garage is kept.
+ */
+class DriverVaultCarAppService : CarAppService() {
+
+ /**
+ * Which hosts may drive this app. The signed allowlist the library ships
+ * covers Android Auto and Automotive; a debug build takes any host, which is
+ * what lets the Desktop Head Unit connect while developing.
+ */
+ override fun createHostValidator(): HostValidator =
+ if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ HostValidator.Builder(applicationContext)
+ .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
+ .build()
+ }
+
+ override fun onCreateSession(): Session = object : Session() {
+ override fun onCreateScreen(intent: Intent): Screen = GarageScreen(carContext)
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/GarageScreen.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/GarageScreen.kt
new file mode 100644
index 0000000..af52c68
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/GarageScreen.kt
@@ -0,0 +1,70 @@
+package com.drivervault.phoneapp.car
+
+import androidx.car.app.CarContext
+import androidx.car.app.model.ItemList
+import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.Row
+import androidx.car.app.model.Template
+
+/** A car and the badge its service history earns it. */
+data class GarageEntry(val car: VaultCar, val status: CarStatus)
+
+/**
+ * The garage as a head unit can hold it: one row per car, its due badge on the
+ * second line, worst first.
+ *
+ * Worst first rather than the phone's own order, because the host will show only
+ * the first few rows of any list — and the car this list exists to mention is the
+ * overdue one, not whichever was added first.
+ */
+class GarageScreen(carContext: CarContext) : VaultScreen>(carContext) {
+
+ override val title: String get() = strings.t("dashboard.title")
+ override val isRoot: Boolean get() = true
+
+ override fun read(api: VaultApi): List =
+ api.cars()
+ .map { car ->
+ // One car's history failing shouldn't cost the whole garage its
+ // list; that car simply has no badge to show.
+ val latest = runCatching { api.latestService(car.id) }.getOrNull()
+ GarageEntry(car, format.serviceStatus(latest, car.currentKm))
+ }
+ .sortedByDescending { it.status.key.severity }
+
+ override fun template(data: List): Template {
+ val list = ItemList.Builder().setNoItemsMessage(strings.t("dashboard.empty"))
+ for (entry in data.take(listLimit())) {
+ list.addItem(row(entry))
+ }
+ return ListTemplate.Builder()
+ .setTitle(title)
+ .setHeaderAction(headerAction)
+ .setActionStrip(refreshStrip)
+ .setSingleList(list.build())
+ .build()
+ }
+
+ private fun row(entry: GarageEntry): Row {
+ val subtitle = listOfNotNull(
+ entry.car.subtitle.takeIf { it.isNotEmpty() },
+ // Which of these cars are somebody else's is worth a head unit's
+ // second line; the phone flies the same chip.
+ when {
+ entry.car.isOwner -> null
+ entry.car.isReadOnly -> strings.t("dashboard.sharedReadOnly")
+ else -> strings.t("dashboard.shared")
+ },
+ ).joinToString(" · ")
+
+ return Row.Builder()
+ .setTitle(entry.car.name)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .addText(tinted(entry.status))
+ .setBrowsable(true)
+ .setOnClickListener {
+ screenManager.push(CarDueScreen(carContext, entry.car))
+ }
+ .build()
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultApi.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultApi.kt
new file mode 100644
index 0000000..ca62ca9
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultApi.kt
@@ -0,0 +1,130 @@
+package com.drivervault.phoneapp.car
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+
+/** The server rejected the token. Only the phone can mint a new one. */
+class VaultUnauthorized : IOException("unauthorized")
+
+/** A car, as much of it as a head unit has room for. Mirrors models.dart's Car. */
+data class VaultCar(
+ val id: String,
+ val name: String,
+ val subtitle: String,
+ val registration: String,
+ val currentKm: Int,
+ val access: String,
+) {
+ val isOwner: Boolean get() = access == "owner"
+ val isReadOnly: Boolean get() = access == "read"
+}
+
+/** A service record's two derived due fields, plus the day it was done. */
+data class VaultService(
+ val date: String,
+ val nextServiceDate: String,
+ val nextServiceKm: Int?,
+)
+
+/**
+ * One thing that wants doing — stored or derived by the server from a document's
+ * expiry or the service schedule. [status] and the two "left" figures are the
+ * server's; nothing here re-derives them.
+ */
+data class VaultReminder(
+ val title: String,
+ val status: String,
+ val daysLeft: Int?,
+ val kmLeft: Int?,
+)
+
+/**
+ * The car screens' read side of the API Server. Deliberately a handful of GETs
+ * over HttpURLConnection rather than a second copy of api.dart: the head unit
+ * only reads, and a client with no writes in it cannot make one by accident.
+ */
+class VaultApi(private val endpoint: VaultStore.Endpoint) {
+
+ fun cars(): List {
+ val items = getArray("/cars")
+ return (0 until items.length()).mapNotNull { i ->
+ items.optJSONObject(i)?.let { car ->
+ VaultCar(
+ id = car.optString("id"),
+ name = car.optString("name"),
+ subtitle = listOf(
+ car.optString("make"),
+ car.optString("model"),
+ car.optInt("year").takeIf { it > 0 }?.toString().orEmpty(),
+ ).filter { it.isNotEmpty() }.joinToString(" "),
+ registration = car.optString("registration"),
+ currentKm = car.optInt("currentKm"),
+ access = car.optString("access", "owner"),
+ )
+ }
+ }
+ }
+
+ /**
+ * The car's most recent service, which is what the due status is read from.
+ * The list comes back newest first, the same order the dashboard takes its
+ * own "latest" from.
+ */
+ fun latestService(carId: String): VaultService? {
+ val items = getArray("/cars/$carId/service-records")
+ val record = items.optJSONObject(0) ?: return null
+ return VaultService(
+ date = record.optString("date"),
+ nextServiceDate = record.optString("nextServiceDate"),
+ nextServiceKm = if (record.isNull("nextServiceKm")) null else record.optInt("nextServiceKm"),
+ )
+ }
+
+ /** A car's reminders, already sorted by the server with the outstanding first. */
+ fun reminders(carId: String): List {
+ val items = getArray("/cars/$carId/reminders")
+ return (0 until items.length()).mapNotNull { i ->
+ items.optJSONObject(i)?.let { rem ->
+ VaultReminder(
+ title = rem.optString("title"),
+ status = rem.optString("status"),
+ daysLeft = if (rem.isNull("daysLeft")) null else rem.optInt("daysLeft"),
+ kmLeft = if (rem.isNull("kmLeft")) null else rem.optInt("kmLeft"),
+ )
+ }
+ }
+ }
+
+ private fun getArray(path: String): JSONArray {
+ val body = get(path)
+ return runCatching { JSONArray(body) }.getOrElse { JSONArray() }
+ }
+
+ private fun get(path: String): String {
+ val connection = URL(endpoint.base + path).openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.setRequestProperty("Authorization", "Bearer " + endpoint.token)
+ connection.setRequestProperty("Accept", "application/json")
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 15_000
+ try {
+ val status = connection.responseCode
+ if (status == HttpURLConnection.HTTP_UNAUTHORIZED) throw VaultUnauthorized()
+ val stream = if (status in 200..299) connection.inputStream else connection.errorStream
+ val body = stream?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
+ if (status !in 200..299) throw IOException(errorMessage(body, status))
+ return body
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ /** The server's own {"error": …}, so a rejection reads as more than a number. */
+ private fun errorMessage(body: String, status: Int): String {
+ val message = runCatching { JSONObject(body).optString("error") }.getOrDefault("")
+ return if (message.isNullOrEmpty()) "HTTP $status" else message
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultScreen.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultScreen.kt
new file mode 100644
index 0000000..48294b7
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultScreen.kt
@@ -0,0 +1,158 @@
+package com.drivervault.phoneapp.car
+
+import android.os.Handler
+import android.os.Looper
+import android.text.SpannableString
+import android.text.Spanned
+import androidx.car.app.CarContext
+import androidx.car.app.Screen
+import androidx.car.app.constraints.ConstraintManager
+import androidx.car.app.model.Action
+import androidx.car.app.model.ActionStrip
+import androidx.car.app.model.CarColor
+import androidx.car.app.model.ForegroundCarColorSpan
+import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.MessageTemplate
+import androidx.car.app.model.Template
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import java.util.concurrent.Executors
+
+/**
+ * A car screen that shows something read from the API Server: it loads once the
+ * host starts it, renders a loading template while the request is out, and turns
+ * a failure into a sentence with a Refresh beside it rather than an empty list.
+ *
+ * Everything the head unit shows is read-only. A screen you cannot type into is
+ * a poor place to edit a car, and a driver is a poor person to ask — the phone
+ * keeps every write.
+ */
+abstract class VaultScreen(carContext: CarContext) : Screen(carContext) {
+
+ protected val strings: CarStrings = CarStrings.of(carContext)
+ protected val format: CarFormat = CarFormat(
+ strings,
+ VaultStore.locale(carContext),
+ VaultStore.dateFormat(carContext),
+ )
+
+ private var data: T? = null
+ private var failure: String? = null
+ private var loading = false
+
+ init {
+ lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ if (data == null && failure == null) load()
+ }
+ })
+ }
+
+ /** The screen's own title, which is also what the loading state is titled. */
+ protected abstract val title: String
+
+ /** True for the screen the host opens first: it gets the app icon, not a back arrow. */
+ protected open val isRoot: Boolean get() = false
+
+ /** Runs off the main thread. Everything a template needs, in one go. */
+ protected abstract fun read(api: VaultApi): T
+
+ /** Renders what [read] returned. */
+ protected abstract fun template(data: T): Template
+
+ final override fun onGetTemplate(): Template {
+ val current = data
+ val message = failure
+ return when {
+ loading || (current == null && message == null) ->
+ ListTemplate.Builder()
+ .setTitle(title)
+ .setHeaderAction(headerAction)
+ .setLoading(true)
+ .build()
+ message != null -> MessageTemplate.Builder(message)
+ .setTitle(title)
+ .setHeaderAction(headerAction)
+ .addAction(refreshAction)
+ .build()
+ else -> template(current!!)
+ }
+ }
+
+ /** Reads again, from the top: the same thing pulling to refresh does on the phone. */
+ protected fun load() {
+ val endpoint = VaultStore.endpoint(carContext)
+ if (endpoint == null) {
+ // Nothing signed in on this phone yet, so there is nothing to read
+ // with — and no way to fix it from here.
+ data = null
+ failure = strings.t("carApp.signIn")
+ loading = false
+ invalidate()
+ return
+ }
+ loading = true
+ failure = null
+ invalidate()
+ IO.execute {
+ val result = runCatching { read(VaultApi(endpoint)) }
+ MAIN.post {
+ if (lifecycle.currentState == Lifecycle.State.DESTROYED) return@post
+ loading = false
+ result
+ .onSuccess { data = it; failure = null }
+ .onFailure { data = null; failure = describe(it) }
+ invalidate()
+ }
+ }
+ }
+
+ protected val headerAction: Action get() = if (isRoot) Action.APP_ICON else Action.BACK
+
+ protected val refreshAction: Action
+ get() = Action.Builder()
+ .setTitle(strings.t("carApp.refresh"))
+ .setOnClickListener { load() }
+ .build()
+
+ protected val refreshStrip: ActionStrip
+ get() = ActionStrip.Builder().addAction(refreshAction).build()
+
+ /**
+ * How many rows this host will show. A head unit takes a handful and drops
+ * the rest, so what is shown has to be the part worth showing.
+ */
+ protected fun listLimit(): Int =
+ carContext.getCarService(ConstraintManager::class.java)
+ .getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
+
+ /** A status sentence in its badge colour — the head unit's version of the pill. */
+ protected fun tinted(status: CarStatus): CharSequence {
+ val color = when (status.key) {
+ StatusKey.OVERDUE -> CarColor.RED
+ StatusKey.SOON -> CarColor.YELLOW
+ StatusKey.OK -> CarColor.GREEN
+ StatusKey.UNKNOWN -> null
+ } ?: return status.label
+ return SpannableString(status.label).apply {
+ setSpan(
+ ForegroundCarColorSpan.create(color),
+ 0,
+ length,
+ Spanned.SPAN_INCLUSIVE_EXCLUSIVE,
+ )
+ }
+ }
+
+ private fun describe(error: Throwable): String = when (error) {
+ // The token the phone holds was refused. Only the phone can mint another.
+ is VaultUnauthorized -> strings.t("carApp.expired")
+ else -> strings.t("carApp.unreachable")
+ }
+
+ private companion object {
+ val IO = Executors.newSingleThreadExecutor()
+ val MAIN = Handler(Looper.getMainLooper())
+ }
+}
diff --git a/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultStore.kt b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultStore.kt
new file mode 100644
index 0000000..d9d9229
--- /dev/null
+++ b/Phone App/android/app/src/main/kotlin/com/drivervault/phoneapp/car/VaultStore.kt
@@ -0,0 +1,56 @@
+package com.drivervault.phoneapp.car
+
+import android.content.Context
+import org.json.JSONObject
+
+/**
+ * What the head unit knows about the phone's session.
+ *
+ * The car screens have no login of their own — a head unit is the last place to
+ * type a password — so they read what the phone app has already persisted and
+ * talk to the same API Server with the same token. That store is
+ * shared_preferences' own file: one process, one set of preferences, so a server
+ * switched on the phone is the server the car reads from, with nothing to keep
+ * in step.
+ *
+ * The key names are lib/servers.dart's and lib/app_settings.dart's, with the
+ * "flutter." prefix shared_preferences puts in front of every key it writes.
+ * Change one there and change it here.
+ */
+object VaultStore {
+ private const val FILE = "FlutterSharedPreferences"
+ private const val PREFIX = "flutter."
+
+ /** The server one request goes to: where it lives, and what identifies us. */
+ data class Endpoint(val base: String, val token: String)
+
+ /**
+ * The active server and its session, or null when there is nothing to read
+ * with — no server signed into yet, or the phone app never run since the
+ * install.
+ */
+ fun endpoint(context: Context): Endpoint? {
+ val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ // Written by ServerRegistry rather than derived here: an untouched home
+ // server carries no address of its own, its base being kDefaultApiBase —
+ // a compile-time --dart-define this side of the app cannot see.
+ val base = prefs.getString(PREFIX + "cc_active_base", "").orEmpty().trim()
+ val id = prefs.getString(PREFIX + "cc_active_server", "home").orEmpty()
+ val raw = prefs.getString(PREFIX + "cc_session_$id", null) ?: return null
+ val token = runCatching { JSONObject(raw).optString("token") }.getOrDefault("")
+ if (base.isEmpty() || token.isEmpty()) return null
+ return Endpoint(base, token)
+ }
+
+ /** The signed-in user's BCP-47 tag, which picks the language and the number formats. */
+ fun locale(context: Context): String =
+ context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ .getString(PREFIX + "cc_locale", "en-US")
+ .let { if (it.isNullOrBlank()) "en-US" else it }
+
+ /** The user's chosen date format: YMD | DMY_NUM | DMY | MDY, as in format.dart. */
+ fun dateFormat(context: Context): String =
+ context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+ .getString(PREFIX + "cc_dateFormat", "YMD")
+ .let { if (it.isNullOrBlank()) "YMD" else it }
+}
diff --git a/Phone App/android/app/src/main/res/xml/automotive_app_desc.xml b/Phone App/android/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..7736f2d
--- /dev/null
+++ b/Phone App/android/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json
index 1e7d182..8db3a35 100644
--- a/Phone App/assets/i18n/da.json
+++ b/Phone App/assets/i18n/da.json
@@ -101,6 +101,12 @@
"other": "{n} serviceposter"
}
},
+ "carApp": {
+ "signIn": "Log ind på telefonen for at se din garage her.",
+ "expired": "Sessionen er udløbet. Log ind igen på telefonen.",
+ "unreachable": "Serveren kunne ikke nås.",
+ "refresh": "Opdater"
+ },
"charging": {
"eyebrow": "OPLADNING OG KORT",
"title": "Ladere i nærheden",
diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json
index 3e2e9b2..35ed15f 100644
--- a/Phone App/assets/i18n/en.json
+++ b/Phone App/assets/i18n/en.json
@@ -101,6 +101,12 @@
"other": "{n} service records"
}
},
+ "carApp": {
+ "signIn": "Sign in on your phone to see your garage here.",
+ "expired": "The session has ended. Sign in again on your phone.",
+ "unreachable": "Could not reach the server.",
+ "refresh": "Refresh"
+ },
"charging": {
"eyebrow": "CHARGING & MAP",
"title": "Nearby chargers",
diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json
index ab30707..ff00fdb 100644
--- a/Phone App/assets/i18n/pl.json
+++ b/Phone App/assets/i18n/pl.json
@@ -103,6 +103,12 @@
"other": "{n} wpisu serwisowego"
}
},
+ "carApp": {
+ "signIn": "Zaloguj się w telefonie, aby zobaczyć tutaj swój garaż.",
+ "expired": "Sesja wygasła. Zaloguj się ponownie w telefonie.",
+ "unreachable": "Nie można połączyć się z serwerem.",
+ "refresh": "Odśwież"
+ },
"charging": {
"eyebrow": "ŁADOWANIE I MAPA",
"title": "Ładowarki w pobliżu",
diff --git a/Phone App/lib/servers.dart b/Phone App/lib/servers.dart
index da05aa4..bb407b8 100644
--- a/Phone App/lib/servers.dart
+++ b/Phone App/lib/servers.dart
@@ -74,6 +74,13 @@ class ServerRegistry extends ChangeNotifier {
static const _activeKey = "cc_active_server";
static String _sessionKey(String id) => "cc_session_$id";
+ /// The active server's resolved base URL, written out for the Android Auto
+ /// service to read (android/.../car/VaultStore.kt). The car screens run
+ /// without a Flutter engine, so they read this store directly — and an
+ /// untouched home entry carries no address of its own to read, its base being
+ /// [kDefaultApiBase], a compile-time define nothing outside Dart can see.
+ static const _activeBaseKey = "cc_active_base";
+
// Pre-multi-server keys, read once by [_migrateLegacy].
static const _legacyTokenKey = "cc_token";
static const _legacyUserKey = "cc_user";
@@ -104,8 +111,13 @@ class ServerRegistry extends ChangeNotifier {
}
final stored = prefs.getString(_activeKey);
activeId = list.any((s) => s.id == stored) ? stored! : kHomeServerId;
+ _syncActiveBase();
}
+ /// Republishes the active server's base URL for the car screens. Called
+ /// wherever which server is active, or where it answers, can have changed.
+ void _syncActiveBase() => _prefs?.setString(_activeBaseKey, activeBase);
+
List _loadList() {
final raw = _prefs?.getString(_listKey);
final decoded = raw == null ? null : _tryDecode(raw);
@@ -232,6 +244,7 @@ class ServerRegistry extends ChangeNotifier {
if (byId(id) == null || id == activeId) return;
activeId = id;
_prefs?.setString(_activeKey, id);
+ _syncActiveBase();
notifyListeners();
}
@@ -259,6 +272,7 @@ class ServerRegistry extends ChangeNotifier {
server.url = next;
}
_saveList();
+ _syncActiveBase();
notifyListeners();
return server;
}
@@ -275,6 +289,7 @@ class ServerRegistry extends ChangeNotifier {
activeId = kHomeServerId;
_prefs?.setString(_activeKey, kHomeServerId);
}
+ _syncActiveBase();
notifyListeners();
}
diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart
index 97602de..09d5a64 100644
--- a/Phone App/test/models_format_test.dart
+++ b/Phone App/test/models_format_test.dart
@@ -534,6 +534,35 @@ void main() {
}
});
+ test("the car screens ask for keys the language files carry", () {
+ // The Android Auto screens are Kotlin (android/.../phoneapp/car/), where the
+ // analyzer and every test above stop. They read these very files by these
+ // very dot paths — CarStrings.kt is i18n.dart's t() over the copies bundled
+ // into the APK — so a key mistyped there resolves to nothing, and a head
+ // unit is where you would find out. Read the sources and ask them here.
+ final dir = Directory("android/app/src/main/kotlin/com/drivervault/phoneapp/car");
+ final sources = dir.listSync().whereType().where((f) => f.path.endsWith(".kt"));
+ expect(sources, isNotEmpty, reason: "the car screens moved — point this test at them");
+
+ final lookups = RegExp(r'strings\.t\(\s*"([^"]+)"');
+ final keys = {
+ for (final file in sources)
+ ...lookups.allMatches(file.readAsStringSync()).map((m) => m.group(1)!),
+ };
+ // The car's own strings are in there, so the pattern still matches calls.
+ expect(keys, contains("carApp.signIn"));
+
+ for (final key in keys) {
+ for (final lang in translatedLanguages) {
+ appSettings.locale = "$lang-${lang.toUpperCase()}";
+ // A plural fails this too, deliberately: CarStrings picks no CLDR
+ // category, so a car screen must not ask for a key that needs one.
+ expect(t(key), isNot(key), reason: "$key is missing from $lang.json");
+ }
+ }
+ appSettings.locale = "pl-PL";
+ });
+
test("a day count reads as prose in each language's own plural forms", () {
appSettings.locale = "en-GB";
expect(t("car.info.daysValue", n: 1), "1 day");
diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md
index f5dbaa9..66f037d 100644
--- a/TRANSLATIONS.md
+++ b/TRANSLATIONS.md
@@ -25,6 +25,7 @@ back to English UI text and say so beneath the picker.
| **Web App** (Vue) | `Web App/web/src/i18n/{en,pl,da}.json` | `Web App/web/src/i18n/index.js` | signed-in profile `locale` (reactive `prefs`) |
| **API Server panel** (Vue) | `API Server/panel/src/i18n/{en,pl,da}.json` | `API Server/panel/src/i18n/index.js` | `localStorage` (`dh-panel-lang`) — the panel has no user profile |
| **Phone App** (Flutter) | `Phone App/assets/i18n/{en,pl,da}.json` | `Phone App/lib/i18n.dart` | signed-in profile `locale` (via `AppSettings`) |
+| **Phone App — Android Auto** (Kotlin) | the same files, read out of the APK's `flutter_assets/` | `Phone App/android/…/phoneapp/car/CarStrings.kt` | the same profile `locale`, read from shared_preferences |
All three use the same JSON shape and the same `t()` contract, so a translator
learns one format.
@@ -106,6 +107,15 @@ file, as do the units.
data out as table columns), client-side validation (the web leans on the
browser's `required`), and the snackbars.
+ The **Android Auto** screens read those very files again, by the same keys, out
+ of the APK — the car has no Flutter engine to run `i18n.dart` in, so
+ `car/CarStrings.kt` does the same lookup over the same JSON. Almost everything
+ it asks for is a key a phone screen already uses, the status badges included;
+ only `carApp.*` (four strings — nobody signed in, token refused, server
+ unreachable, Refresh) is the car's own. Plurals are the one part it leaves out:
+ no car screen needs one, and a plural key comes back as the key, which is what
+ `i18n.dart` does with one it cannot render either.
+
`test/models_format_test.dart` guards two things the analyzer cannot see. The
lookups built from a key at render time (`car.tabs.$key`, `enums.fuelType.$v`,
`admin.roles.$r`, the delete dialog's plural counts, the connected service's