diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt index f4fc8ed..cdb86d2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -31,7 +31,41 @@ import kotlin.math.roundToInt */ /** 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() +fun CarText?.text(): String { + val chars = this?.takeIf { !it.isEmpty }?.toCharSequence() ?: return "" + return chars.withSpansFilledIn() +} + +/** + * Apps write a route's time and length as spans over placeholder characters ("\u0000 · \u0000" + * with a DurationSpan and a DistanceSpan), leaving the host to format them. Plain toString() + * keeps only the placeholders, which is how a route option ended up reading "·". + */ +private fun CharSequence.withSpansFilledIn(): String { + if (this !is android.text.Spanned) return toString() + val spans = getSpans(0, length, androidx.car.app.model.CarSpan::class.java) + .filter { it is androidx.car.app.model.DurationSpan || it is androidx.car.app.model.DistanceSpan } + .sortedBy { getSpanStart(it) } + if (spans.isEmpty()) return toString() + val out = StringBuilder() + var at = 0 + spans.forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + if (start < at || end > length) return@forEach + out.append(this, at, start) + out.append( + when (span) { + is androidx.car.app.model.DurationSpan -> remainingTime(span.durationSeconds) + is androidx.car.app.model.DistanceSpan -> span.distance.display() + else -> "" + }, + ) + at = end + } + out.append(this, at, length) + return out.toString().replace("\u0000", "").trim() +} /** * Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch @@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache(200) fun CarIcon?.image(context: Context): ImageBitmap? { val icon = this?.icon ?: return null val key = icon.cacheKey(context) - iconCache.get(key)?.let { return it } + key?.let { k -> iconCache.get(k)?.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() @@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? { 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) + key?.let { iconCache.put(it, bitmap) } return bitmap } @@ -83,7 +117,15 @@ fun CarIcon?.image(context: Context): ImageBitmap? { * 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 { +/** + * Null for an icon that carries its own pixels: two different bitmaps describe the same, so a + * key would hand one app's turn arrow the picture of whatever bitmap was cached first. + */ +private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String? { + if (type == androidx.core.graphics.drawable.IconCompat.TYPE_BITMAP || + type == androidx.core.graphics.drawable.IconCompat.TYPE_ADAPTIVE_BITMAP || + type == androidx.core.graphics.drawable.IconCompat.TYPE_DATA + ) return null 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"