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:
1 parent
fa569c4030
commit
a203414ceb
18 files changed
+1067
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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>
|
||||
Reference in new issue
Block a user