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