Files
Gearslip/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
T
2026-10-01 20:17:44 -07:00

277 lines
13 KiB
Kotlin

package app.seb3thehacker.gearslip.host
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.model.InputCallbackDelegate
import androidx.car.app.model.OnClickDelegate
import androidx.car.app.model.OnContentRefreshDelegate
import androidx.car.app.model.SearchCallbackDelegate
import androidx.car.app.model.TabCallbackDelegate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
/**
* Turns the Car App Library's model objects into things Compose can draw.
*
* A templated app describes its screen in these types and says nothing about how they should
* look - that is the host's job, and every host looks different. These helpers are the seam
* between the app's description and Gearslip's rendering of it.
*/
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
/**
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
* deserializes its own copies over the binder - so caching by object identity would never hit.
* [describe] is stable across those copies for the same underlying icon (same URI or resource),
* which is what makes it usable as a cache key. Capacity is in entries, not bytes: cover art at
* car-screen size is small and a shelf plus its sections stays well under a hundred distinct
* icons on screen at once.
*/
private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
/**
* Loads an icon the app described.
*
* A [CarIcon] is either a standard type the host is expected to draw itself, or an IconCompat
* pointing into the app's own resources - which is why this needs a Context that can see the
* app, and why a failure here is a missing glyph rather than an error worth stopping for.
*/
fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null
val key = icon.cacheKey(context)
iconCache.get(key)?.let { return it }
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull()
?: run {
GearslipLog.w("icon: loadDrawable returned null for ${icon.describe()}")
return null
}
val rawWidth = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX
val rawHeight = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX
// A handful of apps (Spotify's search glyph, 12x12) hand over an icon so small it turns to
// mush once Compose stretches it up to button size. Rasterizing it larger here instead lets
// Android's own bitmap scaler do that smoothing while the source is still a Drawable, rather
// than Compose scaling an already-tiny texture at draw time.
val scale = (ICON_FALLBACK_PX.toFloat() / maxOf(rawWidth, rawHeight)).coerceAtLeast(1f)
val width = (rawWidth * scale).toInt()
val height = (rawHeight * scale).toInt()
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull() ?: return null
iconCache.put(key, bitmap)
return bitmap
}
/**
* [describe], plus when the app was last installed for a resource icon. Resource ids are only
* numbers inside one build of the app: an update that adds a drawable renumbers the ones after it,
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
* new Saved button came up as its search glyph).
*/
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
return "${describe()}@$installed"
}
/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */
private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) {
androidx.core.graphics.drawable.IconCompat.TYPE_URI,
androidx.core.graphics.drawable.IconCompat.TYPE_URI_ADAPTIVE_BITMAP,
-> "uri=$uri"
androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE -> "resource=$resPackage:$resId"
else -> "type=$type"
}
/** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */
val CarIcon?.standardType: Int? get() = this?.takeIf { it.icon == null }?.type
/**
* Resolves a colour the app asked for. The named colours deliberately stay close to what a
* driver expects them to mean rather than matching the app's brand.
*/
fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
null, CarColor.TYPE_DEFAULT -> default
CarColor.TYPE_CUSTOM -> Color(if (dark) colorDark else color)
CarColor.TYPE_RED -> Color(0xFFE53935)
CarColor.TYPE_GREEN -> Color(0xFF43A047)
CarColor.TYPE_BLUE -> Color(0xFF1E88E5)
CarColor.TYPE_YELLOW -> Color(0xFFFDD835)
else -> default
}
/**
* "500 ft", "2.4 mi". The app picks the unit and its rounding; when that's the other system from
* the one chosen in Gearslip's settings, the distance is converted and rounded here instead.
*/
fun Distance?.display(): String {
val distance = this ?: return ""
val appImperial = displayUnit in setOf(Distance.UNIT_MILES, Distance.UNIT_MILES_P1, Distance.UNIT_FEET, Distance.UNIT_YARDS)
val appMetric = displayUnit in setOf(Distance.UNIT_METERS, Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1)
val imperial = app.seb3thehacker.gearslip.car.CarSettings.imperial()
if ((imperial && appMetric) || (!imperial && appImperial)) return convertDistance(distance.meters(), imperial)
val unit = when (displayUnit) {
Distance.UNIT_METERS -> "m"
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> "mi"
Distance.UNIT_FEET -> "ft"
Distance.UNIT_YARDS -> "yd"
else -> ""
}
val decimals = when (displayUnit) {
Distance.UNIT_KILOMETERS_P1, Distance.UNIT_MILES_P1 -> 1
Distance.UNIT_KILOMETERS, Distance.UNIT_MILES -> if (displayDistance < 10) 1 else 0
else -> 0
}
return "%.${decimals}f $unit".format(distance.displayDistance)
}
private fun Distance.meters(): Double = displayDistance * when (displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}
/**
* Rounded the way a nav app would: feet in steps of 50 under a tenth of a mile, metres in steps
* of 10 (50 past 300) under a kilometre, one decimal under 10 miles or km, whole numbers above.
*/
private fun convertDistance(meters: Double, imperial: Boolean): String {
if (imperial) {
val miles = meters / 1609.344
if (miles < 0.1) return "${(meters / 0.3048 / 50).roundToInt() * 50} ft"
return if (miles < 10) "%.1f mi".format(miles) else "%.0f mi".format(miles)
}
if (meters < 1000) {
val step = if (meters < 300) 10 else 50
return "${(meters / step).roundToInt() * step} m"
}
val km = meters / 1000
return if (km < 10) "%.1f km".format(km) else "%.0f km".format(km)
}
/** "18 min" from the app's remaining-time estimate. */
fun remainingTime(seconds: Long): String {
if (seconds <= 0) return ""
val hours = TimeUnit.SECONDS.toHours(seconds)
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
}
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
val at = this ?: return ""
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
return runCatching {
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(shifted)
}.getOrDefault("")
}
/**
* Tells the app something was tapped.
*
* [OnClickDelegate.isParkedOnly] marks an action the app itself considers unsafe while moving.
* Gearslip has no speed signal yet, so these are allowed through and the decision is recorded
* here as the place to gate them once the sensor channel exists.
*/
fun OnClickDelegate?.click(label: String = "action") {
val delegate = this ?: return
runCatching { delegate.sendClick(doneCallback("a $label tap")) }
.onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") }
}
/**
* Search is a conversation: the host owns the text and the keyboard, and tells the app about
* every keystroke so it can update its results as you type. [submitted] is the explicit "go".
*/
fun SearchCallbackDelegate?.textChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchTextChanged(text, doneCallback("search text")) }
.onFailure { GearslipLog.w("host: could not send search text: ${it.message}") }
}
fun SearchCallbackDelegate?.submitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchSubmitted(text, doneCallback("search submit")) }
.onFailure { GearslipLog.w("host: could not submit a search: ${it.message}") }
}
/** Tells the app which tab the driver picked; the app answers with a new template. */
fun TabCallbackDelegate?.tabSelected(contentId: String) {
val delegate = this ?: return
runCatching { delegate.sendTabSelected(contentId, doneCallback("tab")) }
.onFailure { GearslipLog.w("host: could not select a tab: ${it.message}") }
}
/** Free text typed into a sign-in field, reported the same way search text is. */
fun InputCallbackDelegate?.inputChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputTextChanged(text, doneCallback("input text")) }
.onFailure { GearslipLog.w("host: could not send input text: ${it.message}") }
}
fun InputCallbackDelegate?.inputSubmitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputSubmitted(text, doneCallback("input submit")) }
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
}
/** Tells the app the driver entered or left pan mode, so it can hide its own map controls. */
fun androidx.car.app.navigation.model.PanModeDelegate?.panModeChanged(active: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendPanModeChanged(active, doneCallback("pan mode")) }
.onFailure { GearslipLog.w("host: could not send pan mode: ${it.message}") }
}
/** Tells the app a toggle row was switched; it answers with a template showing the new state. */
fun androidx.car.app.model.OnCheckedChangeDelegate?.checkedChanged(checked: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendCheckedChange(checked, doneCallback("toggle")) }
.onFailure { GearslipLog.w("host: could not send a toggle: ${it.message}") }
}
/** Tells the app which option of a single-choice list was picked. */
fun androidx.car.app.model.OnSelectedDelegate?.selected(index: Int) {
val delegate = this ?: return
runCatching { delegate.sendSelected(index, doneCallback("selection")) }
.onFailure { GearslipLog.w("host: could not send a selection: ${it.message}") }
}
/** The driver asked for fresher content, e.g. by pulling a place list. */
fun OnContentRefreshDelegate?.refresh() {
val delegate = this ?: return
runCatching { delegate.sendContentRefreshRequested(doneCallback("refresh")) }
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
}
private fun doneCallback(label: String) = object : OnDoneCallback {
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable) = GearslipLog.w("host: the app rejected $label")
}
/** An action with nothing to show is one the host should skip rather than draw as a blank. */
fun Action.isDrawable(): Boolean =
icon != null || title.text().isNotEmpty() || type != Action.TYPE_CUSTOM
private const val ICON_FALLBACK_PX = 48