Phone App: the garage on the car's own screen

The one place a service badge is worth reading is the car it belongs to, and
that is the one place the app could not be opened: Android Auto runs no Flutter
engine, so a Flutter app is simply absent from the head unit. The same APK now
carries a second face — Car App Library templates the host draws itself, in
Kotlin under android/app/src/main/kotlin/com/drivervault/phoneapp/car/.

Two screens. The garage lists a car per row with its due badge on the second
line, worst first, because the host renders only the first handful of rows and
the car this list exists to mention is the overdue one rather than whichever was
added first. A tap opens what that car has coming: the odometer, the next
service, and the reminders the server holds for it — typed in and auto-derived
from documents and the service schedule alike, in the order it sorted them.

None of it is a second implementation of the app. VaultStore reads the session
the phone signed in with — the active server's base and token — out of
shared_preferences' own store, which both halves share, so a server switched on
the phone is the server the car reads from with nothing to keep in step; only
cc_active_base is new, because an untouched home entry carries no address of its
own, its base being kDefaultApiBase, a compile-time define nothing outside Dart
can see. CarStrings reads the same assets/i18n files by the same dot paths, so a
badge on the head unit is the string format.dart already puts on the phone, in
the language the account chose: of the 32 keys the car screens ask for, 28 are
keys a phone screen already used, and only carApp.* is theirs. CarFormat is
format.dart's twin — same date pattern and number grouping from the account's
settings, same worst-of-date-and-km service badge. A new test reads the Kotlin
for the keys it looks up and fails if any is missing from a language file, since
the analyzer's reach stops at the Dart.

It only reads. 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.

Three things the README now says out loud. The service is declared IOT, the
closest category the library defines for something that is a garage rather than
a map or a media player, which matters to a store submission and not to a
sideload. The app lock does not reach the head unit: the flag is in memory and
the credentials behind it in encrypted storage, neither readable from the car
service, and there is no fingerprint reader in a dashboard to satisfy it with.
And the home charger is not on there — the chargers endpoint relays its plugin's
payload verbatim with no shape to read, and the serial the control card is
driven by is never persisted, so the car would have nothing to name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-29 18:26:46 +02:00
co-authored by Claude Opus 5
parent fa569c4030
commit a203414ceb
18 changed files with 1067 additions and 0 deletions
+85
View File
@@ -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
```
+8
View File
@@ -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 = "../.."
}
@@ -5,6 +5,15 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Biometric (fingerprint / face) sign-in via local_auth. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- Android Auto. Neither is required: the same APK is a plain phone app on
a phone that never sees a head unit, and the Play Store must not filter
it down to cars because of these. -->
<uses-feature
android:name="android.hardware.type.automotive"
android:required="false"/>
<uses-feature
android:name="android.software.car.templates_host"
android:required="false"/>
<application
android:label="DriverVault"
android:name="${applicationName}"
@@ -32,6 +41,26 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Android Auto — the car screens, drawn by the host from the Car App
Library templates in com.drivervault.phoneapp.car. The descriptor
tells Android Auto this app is a template app; the service is what it
binds to. IOT is the category the library defines that DriverVault
comes closest to: it is a garage, not a map, a media player or a
parking service. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
<service
android:name=".car.DriverVaultCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService"/>
<category android:name="androidx.car.app.category.IOT"/>
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,93 @@
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.SectionedItemList
import androidx.car.app.model.Template
/** One car's next service and everything else it has coming. */
data class CarDue(
val service: VaultService?,
val status: CarStatus,
val reminders: List<VaultReminder>,
)
/**
* 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<CarDue>(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(" · ")
}
@@ -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<String>()
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<String>()
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})$")
}
}
@@ -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<String, Any?> = 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, Any?>): 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
}
}
}
@@ -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)
}
}
@@ -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<List<GarageEntry>>(carContext) {
override val title: String get() = strings.t("dashboard.title")
override val isRoot: Boolean get() = true
override fun read(api: VaultApi): List<GarageEntry> =
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<GarageEntry>): 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()
}
}
@@ -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<VaultCar> {
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<VaultReminder> {
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
}
}
@@ -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<T>(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())
}
}
@@ -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 }
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What Android Auto may run of this app. "template" is the Car App Library:
the host draws the screens under android/app/src/main/kotlin/.../car/, and
the phone UI never appears on the head unit. -->
<automotiveApp>
<uses name="template"/>
</automotiveApp>
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+15
View File
@@ -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<ServerEntry> _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();
}
+29
View File
@@ -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<File>().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 = <String>{
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");
+10
View File
@@ -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