diff --git a/AGENTS.md b/AGENTS.md index 59bcd6d4..680fcbc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1281,23 +1281,68 @@ state - upstream's own 13ac02e8 already made the layers panel a VelaMenu): - The one seam is `core/data/MapDataSource`. `MockMapDataSource` is the default and keeps the entire app usable offline; `google/GoogleMapsDataSource` is the real scraper. -- **Android Auto (`app/car/`).** - - `VelaCarAppService` is a NAVIGATION-category templated `CarAppService` (manifest service + - `xml/automotive_app_desc.xml` + the two `androidx.car.app.*` permissions + application-level - `minCarApiLevel=1`); a sideload appears in the car launcher only with AA developer "Unknown - sources" on, hence `HostValidator.ALLOW_ALL_HOSTS_VALIDATOR`. - - `CarMapScreen` is the whole car UI: NavigationTemplate (Re-center / + / − action strip; - RoutingInfo card with `NavSession.state.maneuverText` + distance while navigating) over a map - surface. - - **The MapLibre-on-car trick: SurfaceCallback surface → `DisplayManager.createVirtualDisplay` → - `Presentation` → plain `MapView`** (MapLibre can't draw to a raw surface). It reuses - `applyDark`/`applyLight` from VelaMapView (made `internal` for this) keyed to - `carContext.isDarkMode`, has its OWN AOSP LocationManager listener for the puck (works with the - phone UI closed), and draws the route from `NavSession.state.route.polyline`. - - Pan/zoom arrive as `onScroll`/`onScale` and move the camera by hand (projection math - - `MapLibreMap.scrollBy` isn't a thing in 11.x). - - The PHONE runs nav (MapViewModel feeds NavSession) and speaks; the car is a display. Car-side - search/route-start is a follow-up; untested on a real head unit yet. +- **Android Auto (`app/car/`) - FULL car-side nav (ported upstream ac487f78 + c409ad21 + + 679119d1 + a2510700 + 381cfd49, 2026-07-23).** + - `VelaCarAppService` is a Hilt `@AndroidEntryPoint` NAVIGATION-category `CarAppService` + injecting the same `:core` singletons the phone uses (NavSession, LocationProvider, + MapDataSource, the three place stores, VoiceGuide, RouteEngine), bundled into `CarDeps` for + the screens. **`app-projected` merges the `CarAppMetadataHolderService` + projection glue and is the documented Android Auto artifact** - the first-cut port lacked it, + and this fork's builds were not listed on the reporting head unit (OnePlus 12) until it was + added. NB the causality is not fully isolated (the AA app-list rescan happened in the same + step), and upstream's interim builds may never have had the issue - the artifact is REQUIRED + regardless (NavigationManager / cluster APIs live in it). `HostValidator.ALLOW_ALL_HOSTS_VALIDATOR` stays (sideload-only + distribution). + - Screens (`car/screen/`): `MainCarScreen` (Home/Work/recents/saved rows, capped at the + template's MAX_ROWS, de-duped by location) -> `SearchCarScreen` (car keyboard search via + MapDataSource) -> `RoutePreviewCarScreen` (alternates + live ETAs) -> `ActiveNavCarScreen` + (maneuver card + lanes + trip updates to the cluster via NavigationManager). The PHONE still + runs NavSession and speaks; `VelaCarSession.onNewIntent` handles assistant/`geo:` NAVIGATE + intents (opaque geo URIs must not go through getQueryParameter - upstream's c409ad21 crash). + - **Two fork additions upstream does not have (2026-07-23):** free-text NAVIGATE destinations + (`geo:0,0?q=central park`) geocode via `MapDataSource.search` into the route preview + (upstream returns null and just opens; coord parsing is matchEntire so "Store 12, 34th St" + is a query, not lat 12 lng 34); and **in-car voice search** - `CarVoiceSearch` feeds the + car's mic (`CarAudioRecord`, Car API 5+, 16 kHz) through `WhisperRecognizer.listen`'s + `PcmSource` seam, so the mic actions on `MainCarScreen` and `SearchCarScreen` (shared flow in `CarVoiceSearch.micAction`) run the same on-device VAD + + Whisper pipeline as the phone. THE ENGINE PREFERENCE IS LAW (2026-07-23 rework): mic taps + branch on `VoiceSearch.resolvedMode` - LOCAL runs CarAudioRecord->Whisper with a + silent-car-mic phone-mic retry (peak-level discriminated), SYSTEM opens the search surface + (its in-field host mic IS the system recognizer; template apps cannot start host voice + sessions). Transcript commands dispatch via `VoiceCommandRouter` AT THE CALLER, never from a + pushed screen's init (constructor args evaluate before the caller's push - anything pushed + from init gets buried; review-verified). RECORD_AUDIO via `carContext.requestPermissions`. + - **Fork batch 2 (2026-07-23), all fork-original:** in-drive search-along-route + (`SearchCarScreen(alongRoute=true)` - corridor filter via `RouteCorridor`, pick calls + `NavSession.addStop`), `CategoriesCarScreen` (GridTemplate; labels localize, queries stay the + stable English literals - same rule as the phone chips), `CarCommands` offline voice grammar + in `:core` (unit-tested; parse-don't-guess: only EXACT keywords command, everything else + searches with the nav verb stripped), faster-route head-up `Alert` (API 5+, one per candidate + key), `ArrivalCarScreen` (save parking -> `ParkingStore`, shared with phone Park) + Find-my-car + landing row + live Home/Work ETA subtitles, resume-into-guidance on session create (guarded on + `!arrived` - an arrived-but-undismissed session must not bounce screens), and `DriveAlerts` + (Settings -> Navigation -> Driving alerts; camera heads-up reads the BUNDLED FlockCameras set + along the route - offline, one announce per camera per trip; speeding = red badge at 5% + 2 + km/h over `speedLimitKmh`). Settings rows live in the NAVIGATION spoke (not a new spoke). + - **`CarMapRenderer` draws the REAL styled map via MapLibre's `MapSnapshotter`** (off-screen -> + Bitmap -> Canvas onto the template surface; ONE shared renderer per session - a second + instance never receives onSurfaceAvailable, so screens switch its MODE instead). Nav mode is + heading-up with look-ahead camera, speed-tightened zoom, traffic-coloured route and a speed + badge; browse is north-up. A smoothing ticker glides puck/bearing between ~1 Hz fixes. + The old first-cut VirtualDisplay+Presentation+MapView path is GONE with it. + - **`MapFonts` (ported with it) patches Liberty's glyphs to Roboto app-wide** (phone map + + snapshotter): fetches the LIVE style JSON, rewrites `glyphs` to the self-hosted + Roboto-over-Noto set, caches as `file://`; probe-guarded so an unreachable font host degrades + to plain Noto, and stale caches evict so a pinned tile snapshot can't go blank. The glyph + host currently defaults to UPSTREAM's GitHub Pages (`MAP_FONTS_URL`, `-PmapFontsUrl=` + override) - mirroring the `map-fonts` release onto this repo's Pages is on ROADMAP. + - **Recents are ONE chronological timeline** (`RecentPlaceStore`/`RecentSearchStore` reshaped, + upstream 381cfd49): typed `RecentPlace`/`RecentQuery` entries mixed by time in the search + overlay (pin vs clock icon) and reused by `MainCarScreen`. The fork's D-pad rings on those + rows are kept through the merge. + - Verification: host gates + unit tests only - there is still NO device gate for AA (the flip + phone cannot project). Real-car testing via the user's head unit; a DHU-based harness phase + is the standing follow-up. - **Settings is HUB-AND-SPOKE (2026-07-22), and the hub ORDER is deliberate:** a short category list (`ui/settings/SettingsHub.kt`) where each row opens its own sub-screen (`ui/settings/sections/*.kt`), in this order: Appearance (theme/map style/units/language) → diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b256c64..12f34e6a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,6 +44,15 @@ android { // Restricted build flavor flag (see productFlavors below). false everywhere except // the `restricted` flavor, which overrides it to true. + // Self-hosted map-font glyphs (Roboto composited over Noto; ui/map/MapFonts). Defaults to + // UPSTREAM's GitHub Pages glyph host for now (the probe guard degrades safely to Noto if + // it is unreachable); mirroring the map-fonts release onto this repo's Pages is on ROADMAP. + // Same local-test override pattern as the manifests: -PmapFontsUrl=http://127.0.0.1:8099. + buildConfigField( + "String", + "MAP_FONTS_URL", + "\"${(project.findProperty("mapFontsUrl") as String?) ?: "https://pimpinpumpkin.github.io/Vela/fonts"}\"", + ) buildConfigField("boolean", "RESTRICTED", "false") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -319,6 +328,7 @@ dependencies { // is the Android Auto artifact). The first-cut port shipped only the base :app template // library, which is why Vela has never appeared in an AA launcher (device report, OnePlus 12). implementation(libs.androidx.car.app.projected) + testImplementation(libs.androidx.car.app.testing) // car-template unit tests debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 37c10719..83becade 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + + @@ -141,6 +144,15 @@ + + + + + + + + diff --git a/app/src/main/java/app/vela/VelaApp.kt b/app/src/main/java/app/vela/VelaApp.kt index db0a2c9c..60f57a1b 100644 --- a/app/src/main/java/app/vela/VelaApp.kt +++ b/app/src/main/java/app/vela/VelaApp.kt @@ -106,7 +106,8 @@ class VelaApp : Application(), coil.ImageLoaderFactory { app.vela.data.FlockCameras.refresh(this@VelaApp, app.vela.BuildConfig.FLOCK_MANIFEST_URL) } app.vela.ui.SimLocation.init(this) - app.vela.ui.VoiceSearch.init(this) // voice-search toggle + engine/provider prefs (mic in the search bar) + app.vela.ui.VoiceSearch.init(this) + app.vela.ui.map.MapFonts.init(this) // Roboto basemap glyphs (cached patched style + async refresh) // voice-search toggle + engine/provider prefs (mic in the search bar) app.vela.ui.LiveReviews.init(this) app.vela.ui.ShowReviews.init(this) app.vela.ui.LoadPhotos.init(this) diff --git a/app/src/main/java/app/vela/car/CarMapRenderer.kt b/app/src/main/java/app/vela/car/CarMapRenderer.kt new file mode 100644 index 00000000..83d6f191 --- /dev/null +++ b/app/src/main/java/app/vela/car/CarMapRenderer.kt @@ -0,0 +1,643 @@ +package app.vela.car + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.RectF +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import app.vela.core.data.RouteEngine +import app.vela.core.data.tiles.MapStyle +import app.vela.core.location.LocationProvider +import app.vela.core.model.LatLng +import app.vela.core.model.Route +import app.vela.core.model.bearingTo +import app.vela.core.model.destinationPoint +import app.vela.core.nav.NavSession +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng as MLLatLng +import org.maplibre.android.snapshotter.MapSnapshot +import org.maplibre.android.snapshotter.MapSnapshotter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.ln +import kotlin.math.roundToInt + +/** + * Renders the REAL styled Vela map (OpenFreeMap Liberty vector — streets, labels, POIs) onto an + * Android Auto template surface via MapLibre's stable public [MapSnapshotter] (off-screen map → + * Bitmap), with the route + puck overlaid. Not 60 fps (a snapshot is ~100–300 ms), but for a nav + * map that follows position it's a genuine moving map. + * + * Two modes, driven by [NavSession.navigating]: + * - **Nav** — heading-up, camera looks AHEAD of the puck (puck sits in the lower third so you see + * the road you're driving into), zoom tightens as you slow, the route line is drawn coloured by + * live traffic, and a current-speed badge shows. + * - **Browse** — north-up, centred on you, NO route (so a finished trip's line doesn't linger). + * + * At night the snapshot is tinted dark (a real dark vector style via the snapshotter is a follow-up). + */ +class CarMapRenderer( + private val carContext: CarContext, + private val locationProvider: LocationProvider, + private val navSession: NavSession, + private val routeEngine: RouteEngine? = null, +) : SurfaceCallback { + + private val scope = CoroutineScope(Dispatchers.Main.immediate) + private var collectJob: Job? = null + + private var surface: android.view.Surface? = null + private var width = 0 + private var height = 0 + + private var snapshotter: MapSnapshotter? = null + // True when the snapshotter carries the REAL dark style (CarNightStyle) - the tint-filter + // fallback only applies when this is false. snapNight = the day/night state the current + // snapshotter was built for, so a mid-session flip rebuilds it with the other palette. + private var nightStyled = false + private var snapNight = false + private var snapTraffic = false + // The last surface container, kept so a mid-session day/night or traffic flip can replay the + // surface path to rebuild the snapshotter (its style is baked in at creation). + private var lastContainer: SurfaceContainer? = null + private var snapWidth = 0 + private var snapHeight = 0 + private var lastSnapshot: MapSnapshot? = null + private var rendering = false + private var dirty = false + + // Camera / motion state. `puck`/`bearing` are the DISPLAYED (smoothed) values the draw + camera + // code reads; `targetPuck`/`targetBearing` are the latest GPS truth. A steady ticker glides the + // displayed values toward the targets between the ~1 Hz fixes, so the map no longer lurches once + // a second (the "constantly moving around / choppy" report) — it dead-reckons like a real nav map. + @Volatile private var puck: LatLng? = null + @Volatile private var targetPuck: LatLng? = null + @Volatile private var speedMps: Double = 0.0 + @Volatile private var speedLimitKmh: Double? = null + private var center: LatLng? = null + private var zoom = 16.5 + private var bearing = 0.0 + private var targetBearing = 0.0 + private var following = true // pan turns this off in browse + private var tickerJob: Job? = null + + // Preview mode: draw this route framed (no puck-follow) — used by the route-preview screen. + @Volatile private var previewRoute: Route? = null + @Volatile private var lastPanMs = 0L // last user pan/zoom; auto-recenter kicks in after RECENTER_MS + + private companion object { + const val RECENTER_MS = 6000L // auto-recenter this long after a pan + const val TICK_MS = 70L // render-loop cadence (snapshots gate the real fps below this) + const val PUCK_EASE = 0.28 // fraction of the remaining gap closed per tick (~1 s to converge) + const val BEARING_EASE = 0.22 + const val STOPPED_MPS = 1.0 // below this, treat as parked: don't trust GPS-course noise + const val SNAP_MAX_M = 40.0 // map-match to the route only within this distance (else off-route) + } + + private fun navigating() = navSession.state.value.navigating + + /** Follow the puck (browse north-up / nav heading-up) — the landing + active-nav screens. */ + fun follow() { + previewRoute = null + following = true + requestRender() + } + + /** Step the zoom (map-control buttons); pins follow off briefly so the change is visible. */ + fun zoomBy(delta: Double) { + zoom = (zoom + delta).coerceIn(2.0, 20.0) + lastPanMs = android.os.SystemClock.uptimeMillis() + following = false + requestRender() + } + + /** Frame [route] on the surface for the route-preview screen (no live follow). */ + fun showPreview(route: Route?) { + previewRoute = route + following = false + frameRoute(route) + requestRender() + } + + private val drivenPaint = strokePaint("#7b8494", 14f) + private val puckPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#2ee6a6") } + private val puckStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f + } + private val bgPaint = Paint().apply { color = Color.parseColor("#0f1420") } + // Traffic colours (match the phone route line): free-flow blue → amber → red. + private val trafficPaints = mapOf( + 0 to strokePaint("#4c8dff", 15f), // free-flowing + 1 to strokePaint("#f9a825", 15f), // moderate + 2 to strokePaint("#e53935", 15f), // heavy + 3 to strokePaint("#8e1414", 15f), // severe + ) + private val badgeBg = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#cc1b1f2b") } + // Speeding: the same badge with a red fill (Settings -> Navigation -> Driving alerts). + private val badgeBgOver = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#e0c62828") } + private val badgeNum = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 42f; isFakeBoldText = true + } + private val badgeUnit = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#b9c0cc"); textAlign = Paint.Align.CENTER; textSize = 18f + } + private val limitDisc = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE } + private val limitRing = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#d32f2f"); style = Paint.Style.STROKE; strokeWidth = 7f + } + private val limitNum = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#111111"); textAlign = Paint.Align.CENTER; textSize = 36f; isFakeBoldText = true + } + + // Night tint: darken + slight blue base so the map isn't a blinding white slab after dark. + private val nightFilter = ColorMatrixColorFilter( + ColorMatrix(floatArrayOf( + 0.34f, 0f, 0f, 0f, 6f, + 0f, 0.34f, 0f, 0f, 10f, + 0f, 0f, 0.40f, 0f, 24f, + 0f, 0f, 0f, 1f, 0f, + )), + ) + private val nightBitmapPaint = Paint().apply { colorFilter = nightFilter } + private val dayBitmapPaint = Paint() + + private fun strokePaint(hex: String, w: Float) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor(hex); style = Paint.Style.STROKE; strokeWidth = w + strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND + } + + // The HOST's day/night signal, not a wall-clock guess (a fixed 6/19 split is wrong across + // seasons and latitudes; the car host already computes this from location + time). + private fun isNight(): Boolean = runCatching { carContext.isDarkMode }.getOrDefault(false) + + /** Pause buffer production while a NON-MAP template covers the surface: the host layers the + * search/list templates over the shared surface, and frames we keep posting underneath bleed + * through as tearing blocks (head-unit report). Map screens call [start] on foreground + * (which unpauses) and [pause] from onStop. */ + @Volatile private var paused = false + fun pause() { paused = true } + + fun start() { + paused = false + requestRender() + collectJob?.cancel() + collectJob = scope.launch { + locationProvider.updates().collect { loc -> + // Puck feed gate: once we have a good GPS puck, IGNORE network/fused/coarse fixes. The + // phone interleaves gps(hAcc≈3 m) with network/fused fixes (hAcc 18–86 m) even while + // parked, and taking them ungated jumped the arrow around a stationary car. The nav + // engine already gates the same way (GPS-only, ≤50 m). Before any puck (bootstrap), + // accept anything so browse shows a location immediately. + val goodGps = loc.provider == android.location.LocationManager.GPS_PROVIDER && + (!loc.hasAccuracy() || loc.accuracy <= 50f) + if (!goodGps && puck != null) return@collect + val raw = LatLng(loc.latitude, loc.longitude) + // Map-match to the route while navigating so the puck rides the road (Google/Waze do + // this); off-route/far falls back to the raw fix. + val here = if (navigating()) snapToRoute(raw) ?: raw else raw + targetPuck = here + if (puck == null) puck = here // first fix: snap into place, don't glide in from null + speedMps = if (loc.hasSpeed()) loc.speed.toDouble() else 0.0 + // Posted speed limit (offline graph's max_speed) while navigating — null off-graph/online. + // The graph LocationIndex snap runs OFF the main thread (this collector is on + // Main.immediate; a synchronous mmap snap every fix would jank the render loop). + speedLimitKmh = if (navigating()) + withContext(Dispatchers.Default) { runCatching { routeEngine?.currentRoadLimit(here.lat, here.lng) }.getOrNull() } + else null + if (previewRoute != null && !navigating()) { requestRender(); return@collect } // preview owns the camera + // Auto-recenter a few seconds after the user pans (Google-style: pan to look around, then snap back). + if (!following && android.os.SystemClock.uptimeMillis() - lastPanMs > RECENTER_MS) following = true + if (following) { + // Heading source (the ticker eases toward this, so it never snap-rotates): + // 1. Moving with a TRUSTWORTHY GPS course → the actual travel direction (what the + // phone nav uses; correct even when off-route). A stationary or low-confidence + // bearing is noise (bAcc seen at 134° while parked) — that spun the map, so it's + // rejected via the speed + bearing-accuracy gate. + // 2. Else the route SEGMENT bearing (stable road direction; a nearest-vertex search + // flickered between adjacent points and swung the view even while driving). + // 3. Else hold the last heading — never chase noise. + targetBearing = if (navigating()) { + val trustCourse = speedMps > STOPPED_MPS && loc.hasBearing() && + (!loc.hasBearingAccuracy() || loc.bearingAccuracyDegrees <= 45f) + if (trustCourse) loc.bearing.toDouble() else routeHeading(here) ?: targetBearing + } else 0.0 // browse = north-up + } + // Render once per fix too (not only from the ticker): draw() reads LIVE state, so a + // route swap / reroute / faster-route adoption, per-span traffic recolour, the speed + // badge, and the auto-recenter flip must repaint even when the puck is momentarily + // stationary (fixes still arrive ~1 Hz while parked). The ticker adds the smooth + // between-fix interpolation on top; both go through the rendering/dirty guard. + requestRender() + } + } + startTicker() + } + + /** Steady loop that eases the displayed puck/bearing toward the GPS targets, so the map glides + * between the ~1 Hz fixes instead of lurching once a second. Renders only when something moved + * (a standstill draws nothing new); snapshots cap the true fps well below the tick rate, but each + * frame lands partway to the target, which reads as smooth motion rather than a jump. */ + private fun startTicker() { + tickerJob?.cancel() + tickerJob = scope.launch { + while (true) { + var moved = false + val tp = targetPuck; val p = puck + if (tp != null && p != null) { + val nlat = p.lat + (tp.lat - p.lat) * PUCK_EASE + val nlng = p.lng + (tp.lng - p.lng) * PUCK_EASE + if (abs(nlat - p.lat) > 1e-7 || abs(nlng - p.lng) > 1e-7) { puck = LatLng(nlat, nlng); moved = true } + } + val db = shortestAngleDelta(bearing, targetBearing) + if (abs(db) > 0.2) { bearing = normalizeAngle(bearing + db * BEARING_EASE); moved = true } + if (following && previewRoute == null) puck?.let { center = it } + if (moved) requestRender() + kotlinx.coroutines.delay(TICK_MS) + } + } + } + + /** Signed shortest angular delta a→b in degrees, in (-180, 180]. */ + private fun shortestAngleDelta(a: Double, b: Double): Double { + var d = (b - a) % 360.0 + if (d < -180.0) d += 360.0 + if (d > 180.0) d -= 360.0 + return d + } + + private fun normalizeAngle(a: Double): Double = ((a % 360.0) + 360.0) % 360.0 + + fun stop() { + collectJob?.cancel() + collectJob = null + tickerJob?.cancel() + tickerJob = null + // Session teardown: release the native snapshotter too (onSurfaceDestroyed may not fire on an + // abnormal projection end). Safe here because stop() is session-scoped, not per-screen. + runCatching { snapshotter?.cancel() } + snapshotter = null + lastSnapshot = null + } + + override fun onSurfaceAvailable(container: SurfaceContainer) { + lastContainer = container + surface = container.surface + width = container.width + height = container.height + // Clear any in-flight-render state: a surface swap (screen transition) cancels the previous + // snapshot before its callback fires, so `rendering` would otherwise stay stuck true forever + // and every future requestRender would no-op — the map freezes after a screen change. + rendering = false; dirty = false + if (width <= 0 || height <= 0) return + runCatching { MapLibre.getInstance(carContext) } + center = center ?: puck ?: locationProvider.lastKnown() + // Reuse the existing snapshotter when the surface size is unchanged. A screen transition + // (Main→Preview→ActiveNav) re-delivers onSurfaceAvailable at the SAME size; recreating the + // snapshotter each time span up a fresh `vela-car-map` virtual display and reloaded the whole + // style (the repeated CompositionEngine createDisplay churn = a visible map flash/reload). + val s = snapshotter + val night = isNight() + val traffic = app.vela.ui.Traffic.on.value + if (s != null && snapWidth == width && snapHeight == height && snapNight == night && snapTraffic == traffic) { + requestRender() + return + } + runCatching { s?.cancel() } + // Same style resolution as the phone map: MapFonts' Roboto-patched Liberty when its + // cache is ready (read + handed over as JSON - the snapshotter has no file:// branch + // of its own), else the plain URL. Without this the car screen kept Noto after the + // phone flipped to Roboto. + val effectiveStyle = app.vela.ui.map.MapFonts.effective(MapStyle.LIBERTY.uri) + val patchedJson = if (effectiveStyle.startsWith("file://")) { + runCatching { java.io.File(effectiveStyle.removePrefix("file://")).readText() } + .getOrNull()?.takeIf { it.isNotBlank() } + } else null + // Night: the REAL dark palette (the phone's applyDark, as a JSON transform) when we hold + // the style JSON; a failed/unavailable transform falls back to the draw-time tint filter. + val darkJson = if (night) patchedJson?.let(CarNightStyle::darken) else null + nightStyled = darkJson != null + // The phone's live-traffic overlay toggle carries into the car browse map (nav mode draws + // its own per-segment route traffic; the raster just adds ambient congestion around it). + val styleJson = (darkJson ?: patchedJson)?.let { base -> + if (traffic) CarNightStyle.withTraffic(base) ?: base else base + } + val opts = MapSnapshotter.Options(width, height) + .let { if (styleJson != null) it.withStyleJson(styleJson) else it.withStyle(MapStyle.LIBERTY.uri) } + .withPixelRatio(1.0f) + .withLogo(false) + // Subclass with a no-op addOverlay (the protected hook that stamps the logo + the + // "(c) OpenFreeMap / OpenMapTiles / OpenStreetMap" text onto every snapshot): no on-map + // attribution on the car surface (user request). The ODbL credit stays in Settings -> + // About - the space-constrained placement OSM's attribution guidance allows. + snapshotter = runCatching { + object : MapSnapshotter(carContext, opts) { + override fun addOverlay(snapshot: org.maplibre.android.snapshotter.MapSnapshot) = Unit + } + }.getOrNull() + snapWidth = width; snapHeight = height; snapNight = night; snapTraffic = traffic + requestRender() + } + + override fun onSurfaceDestroyed(container: SurfaceContainer) { + lastContainer = null + surface = null + runCatching { snapshotter?.cancel() } + snapshotter = null + snapWidth = 0; snapHeight = 0 + lastSnapshot = null + rendering = false; dirty = false // the cancelled snapshot's callback won't fire — unstick the flag + } + + override fun onVisibleAreaChanged(visibleArea: Rect) { requestRender() } + override fun onStableAreaChanged(stableArea: Rect) { requestRender() } + + override fun onScroll(distanceX: Float, distanceY: Float) { + val snap = lastSnapshot ?: return + following = false + lastPanMs = android.os.SystemClock.uptimeMillis() + val cx = width / 2f; val cy = height / 2f + val ll = runCatching { snap.latLngForPixel(PointF(cx + distanceX, cy + distanceY)) }.getOrNull() ?: return + center = LatLng(ll.latitude, ll.longitude) + requestRender() + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + if (scaleFactor <= 0f) return + following = false // else the next nav frame overwrites the pinch zoom with navZoom() + lastPanMs = android.os.SystemClock.uptimeMillis() + zoom = (zoom + ln(scaleFactor.toDouble()) / ln(2.0)).coerceIn(2.0, 20.0) + requestRender() + } + + /** Zoom that tightens as you slow (more detail at junctions) and widens at speed. */ + private fun navZoom(): Double { + val kmh = speedMps * 3.6 + return when { + kmh < 15 -> 17.5 + kmh < 40 -> 17.0 + kmh < 70 -> 16.3 + kmh < 100 -> 15.7 + else -> 15.2 + } + } + + private fun requestRender() { + if (paused) return + // A day/night or traffic flip mid-session must rebuild the snapshotter (the style is baked + // in at creation): no surface event fires at sunset, so without this the map kept the old + // palette for the rest of the drive (review finding). Rebuild via the same surface path. + if (snapshotter != null && width > 0 && height > 0 && + (isNight() != snapNight || app.vela.ui.Traffic.on.value != snapTraffic) + ) { + snapWidth = 0 // force the reuse check to miss + lastContainer?.let { onSurfaceAvailable(it) } + } + val snap = snapshotter + val here = center + if (snap == null || here == null) return + if (rendering) { dirty = true; return } + rendering = true + + val nav = navigating() + val follow = following // false while the user has panned (until auto-recenter) + if (nav && follow) zoom = navZoom() + // Look-ahead: while following in nav, push the camera target forward along the heading so the + // puck sits in the lower third (Google-style). Browse/panned keeps the plain centre. + val target = if (nav && follow && puck != null) { + val mpp = 156543.03392 * cos(Math.toRadians(here.lat)) / Math.pow(2.0, zoom) + val aheadMeters = height * 0.22 * mpp // ~22% of the view up-screen + puck!!.destinationPoint(aheadMeters, bearing) + } else here + + val cam = CameraPosition.Builder() + .target(MLLatLng(target.lat, target.lng)) + .zoom(zoom) + .bearing(if (nav && follow) bearing else 0.0) + .tilt(0.0) + .build() + runCatching { + snap.setSize(width, height) + snap.setCameraPosition(cam) + snap.start({ result -> + rendering = false + lastSnapshot = result + draw(result) + if (dirty) { dirty = false; requestRender() } + }, { rendering = false }) + }.onFailure { rendering = false } + } + + private fun draw(snap: MapSnapshot) { + val s = surface ?: return + if (!s.isValid || width <= 0 || height <= 0) return + val bmp = runCatching { snap.bitmap }.getOrNull() + val canvas: Canvas = try { s.lockCanvas(null) } catch (t: Throwable) { return } + try { + if (bmp != null) { + val paint = if (isNight() && !nightStyled) nightBitmapPaint else dayBitmapPaint + canvas.drawBitmap(bmp, Rect(0, 0, bmp.width, bmp.height), Rect(0, 0, width, height), paint) + } else { + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) + } + val sx = if (bmp != null) width.toFloat() / bmp.width else 1f + val sy = if (bmp != null) height.toFloat() / bmp.height else 1f + // Each overlay guarded so a projection hiccup in one can't blank the rest. + val preview = previewRoute + if (navigating()) { + runCatching { drawRoute(canvas, snap, sx, sy) } + runCatching { drawSpeed(canvas) } + } else if (preview != null && preview.polyline.size >= 2) { + // Preview screen: the whole route in blue, framed. + runCatching { canvas.drawPath(pathOf(snap, preview.polyline, preview.polyline.indices, sx, sy), trafficPaints[0]!!) } + } + runCatching { drawPuck(canvas, snap, sx, sy) } // puck always drawn + } finally { + runCatching { s.unlockCanvasAndPost(canvas) } + } + } + + private fun project(snap: MapSnapshot, p: LatLng, sx: Float, sy: Float): PointF? { + val px = runCatching { snap.pixelForLatLng(MLLatLng(p.lat, p.lng)) }.getOrNull() ?: return null + return PointF(px.x * sx, px.y * sy) + } + + private fun drawRoute(canvas: Canvas, snap: MapSnapshot, sx: Float, sy: Float) { + val route = navSession.state.value.route ?: return + val poly = route.polyline + if (poly.size < 2) return + val here = puck + var splitI = 0 + if (here != null) { + var best = Double.MAX_VALUE + for (i in poly.indices) { + val d = hypot(poly[i].lat - here.lat, poly[i].lng - here.lng) + if (d < best) { best = d; splitI = i } + } + } + // Draw the WHOLE route ahead in blue first (robust baseline — always shows), grey behind. + if (splitI > 0) canvas.drawPath(pathOf(snap, poly, 0..splitI, sx, sy), drivenPaint) + if (splitI < poly.lastIndex) canvas.drawPath(pathOf(snap, poly, splitI..poly.lastIndex, sx, sy), trafficPaints[0]!!) + + // Overlay per-span traffic colour on the ahead portion (best-effort; the blue baseline shows + // regardless if this finds nothing). + val spans = route.trafficSpans + if (spans.isEmpty() || route.distanceMeters <= 0) return + val cum = DoubleArray(poly.size) + for (i in 1 until poly.size) cum[i] = cum[i - 1] + haversine(poly[i - 1], poly[i]) + fun levelAt(m: Double): Int { + for (sp in spans) if (m >= sp.startMeters && m < sp.startMeters + sp.lengthMeters) return sp.level.coerceIn(1, 3) + return 0 + } + for (i in splitI until poly.lastIndex) { + val lvl = levelAt((cum[i] + cum[i + 1]) / 2.0) + if (lvl == 0) continue // free-flow already blue from the baseline + val a = project(snap, poly[i], sx, sy) ?: continue + val b = project(snap, poly[i + 1], sx, sy) ?: continue + canvas.drawLine(a.x, a.y, b.x, b.y, trafficPaints[lvl] ?: trafficPaints[0]!!) + } + } + + private fun pathOf(snap: MapSnapshot, poly: List, range: IntRange, sx: Float, sy: Float): Path { + val p = Path(); var started = false + for (i in range) { + val pt = project(snap, poly[i], sx, sy) ?: continue + if (!started) { p.moveTo(pt.x, pt.y); started = true } else p.lineTo(pt.x, pt.y) + } + return p + } + + private fun drawPuck(canvas: Canvas, snap: MapSnapshot, sx: Float, sy: Float) { + val here = puck ?: return + val pt = project(snap, here, sx, sy) ?: return + val r = 22f + val path = Path().apply { + moveTo(pt.x, pt.y - r) + lineTo(pt.x + r * 0.8f, pt.y + r * 0.7f) + lineTo(pt.x, pt.y + r * 0.3f) + lineTo(pt.x - r * 0.8f, pt.y + r * 0.7f) + close() + } + canvas.drawPath(path, puckPaint) + canvas.drawPath(path, puckStroke) + } + + /** Bottom-right current-speed badge (km/h or mph per the user's units), plus a Google-style + * round speed-limit sign to its left when the road's posted limit is known (offline graph). */ + private fun drawSpeed(canvas: Canvas) { + val imperial = app.vela.ui.Units.imperial.value + val v = if (imperial) speedMps * 2.236936 else speedMps * 3.6 + val num = v.roundToInt().coerceAtLeast(0) + val unit = if (imperial) "mph" else "km/h" + val cx = width - 62f; val cy = height - 66f; val rad = 46f + // Over the posted limit (with a small tolerance: 5% + 2 km/h, so GPS speed jitter at + // exactly the limit doesn't strobe the badge) -> red fill, when the warning is enabled. + val over = app.vela.ui.DriveAlerts.speeding.value && speedLimitKmh?.let { lim -> + speedMps * 3.6 > lim * 1.05 + 2.0 + } == true + canvas.drawRoundRect(RectF(cx - rad, cy - rad, cx + rad, cy + rad), 20f, 20f, if (over) badgeBgOver else badgeBg) + canvas.drawText(num.toString(), cx, cy + 6f, badgeNum) + canvas.drawText(unit, cx, cy + 30f, badgeUnit) + + // Speed-limit sign (white disc, red ring) to the left of the speed badge. + speedLimitKmh?.let { kmh -> + val limit = (if (imperial) kmh / 1.609344 else kmh).roundToInt() + if (limit <= 0) return + val lx = cx - 2 * rad - 18f; val ly = cy; val lr = 42f + canvas.drawCircle(lx, ly, lr, limitDisc) + canvas.drawCircle(lx, ly, lr, limitRing) + canvas.drawText(limit.toString(), lx, ly + 14f, limitNum) + } + } + + /** Center the camera on [route] and pick a zoom that fits its bounding box (preview screen). */ + private fun frameRoute(route: Route?) { + val poly = route?.polyline ?: return + if (poly.isEmpty()) return + var minLat = 90.0; var maxLat = -90.0; var minLng = 180.0; var maxLng = -180.0 + for (p in poly) { + minLat = Math.min(minLat, p.lat); maxLat = Math.max(maxLat, p.lat) + minLng = Math.min(minLng, p.lng); maxLng = Math.max(maxLng, p.lng) + } + val c = LatLng((minLat + maxLat) / 2, (minLng + maxLng) / 2) + center = c + val span = Math.max(maxLat - minLat, (maxLng - minLng) * cos(Math.toRadians(c.lat))).coerceAtLeast(0.0015) + zoom = (ln(360.0 / (span * 1.7)) / ln(2.0)).coerceIn(3.0, 16.0) + bearing = 0.0 + } + + /** Heading = the bearing of the route SEGMENT the puck is on, not the bearing to the nearest + * vertex. A nearest-vertex search flickers between adjacent points on the ~2 m GPS jitter you get + * while parked, swinging the whole heading-up view ("moving in all directions in the driveway"). + * The segment bearing is stable: small jitter keeps you on the same segment, so the heading holds. */ + private fun routeHeading(here: LatLng): Double? { + val poly = navSession.state.value.route?.polyline ?: return null + if (poly.size < 2) return null + var bestSeg = 0; var best = Double.MAX_VALUE + for (i in 0 until poly.lastIndex) { + val d = distToSegment(here, poly[i], poly[i + 1]) + if (d < best) { best = d; bestSeg = i } + } + return poly[bestSeg].bearingTo(poly[bestSeg + 1]) + } + + /** Approx point→segment distance in a local equirectangular frame (lng scaled by cos lat). Good + * enough to pick the nearest segment; not used for display metres. */ + private fun distToSegment(p: LatLng, a: LatLng, b: LatLng): Double { + val cosLat = cos(Math.toRadians(p.lat)) + val ax = a.lng * cosLat; val ay = a.lat + val bx = b.lng * cosLat; val by = b.lat + val px = p.lng * cosLat; val py = p.lat + val dx = bx - ax; val dy = by - ay + val len2 = dx * dx + dy * dy + if (len2 < 1e-12) return hypot(px - ax, py - ay) + val t = (((px - ax) * dx + (py - ay) * dy) / len2).coerceIn(0.0, 1.0) + return hypot(px - (ax + t * dx), py - (ay + t * dy)) + } + + /** MAP MATCHING: project [here] onto the nearest ROUTE segment and return the on-road point — but + * only when it's within [SNAP_MAX_M] of the route (you're on it). This is how Google/Waze keep the + * puck glued to the road and glide it smoothly; it also fully absorbs residual GPS jitter and any + * stray coarse fix. Off-route / far (before a reroute lands) → null, so the raw fix is used and a + * genuine deviation still reads as off the line. */ + private fun snapToRoute(here: LatLng): LatLng? { + val poly = navSession.state.value.route?.polyline ?: return null + if (poly.size < 2) return null + val cosLat = cos(Math.toRadians(here.lat)) + val px = here.lng * cosLat; val py = here.lat + var best = Double.MAX_VALUE; var bx = 0.0; var by = 0.0 + for (i in 0 until poly.lastIndex) { + val a = poly[i]; val b = poly[i + 1] + val ax = a.lng * cosLat; val ay = a.lat + val dx = b.lng * cosLat - ax; val dy = b.lat - ay + val len2 = dx * dx + dy * dy + val t = if (len2 < 1e-12) 0.0 else (((px - ax) * dx + (py - ay) * dy) / len2).coerceIn(0.0, 1.0) + val projx = ax + t * dx; val projy = ay + t * dy + val d = hypot(px - projx, py - projy) + if (d < best) { best = d; bx = projx; by = projy } + } + if (best * 111_320.0 > SNAP_MAX_M) return null // too far from the route → keep the raw fix + return LatLng(by, bx / cosLat) // unproject the equirectangular point back to lat/lng + } + + private fun haversine(a: LatLng, b: LatLng): Double { + val R = 6371000.0 + val dLat = Math.toRadians(b.lat - a.lat); val dLng = Math.toRadians(b.lng - a.lng) + val la = Math.toRadians(a.lat); val lb = Math.toRadians(b.lat) + val h = Math.sin(dLat / 2).let { it * it } + cos(la) * cos(lb) * Math.sin(dLng / 2).let { it * it } + return 2 * R * Math.asin(Math.min(1.0, Math.sqrt(h))) + } +} diff --git a/app/src/main/java/app/vela/car/CarMapScreen.kt b/app/src/main/java/app/vela/car/CarMapScreen.kt deleted file mode 100644 index 06df92c2..00000000 --- a/app/src/main/java/app/vela/car/CarMapScreen.kt +++ /dev/null @@ -1,338 +0,0 @@ -package app.vela.car - -import android.app.Presentation -import android.graphics.PointF -import android.hardware.display.DisplayManager -import android.hardware.display.VirtualDisplay -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager -import android.os.Looper -import androidx.car.app.AppManager -import androidx.car.app.CarContext -import androidx.car.app.Screen -import androidx.car.app.SurfaceCallback -import androidx.car.app.SurfaceContainer -import androidx.car.app.model.Action -import androidx.car.app.model.ActionStrip -import androidx.car.app.model.Distance -import androidx.car.app.model.Template -import androidx.car.app.navigation.model.NavigationTemplate -import androidx.car.app.navigation.model.RoutingInfo -import androidx.car.app.navigation.model.Step -import androidx.core.content.getSystemService -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope -import app.vela.R -import app.vela.core.data.tiles.MapStyle -import app.vela.core.nav.NavSession -import app.vela.ui.Units -import app.vela.ui.map.applyDark -import app.vela.ui.map.applyLight -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.maplibre.android.MapLibre -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView -import org.maplibre.android.maps.Style -import org.maplibre.android.style.layers.CircleLayer -import org.maplibre.android.style.layers.LineLayer -import org.maplibre.android.style.layers.Property -import org.maplibre.android.style.layers.PropertyFactory -import org.maplibre.android.style.sources.GeoJsonSource -import org.maplibre.geojson.FeatureCollection -import org.maplibre.geojson.LineString -import org.maplibre.geojson.Point -import kotlin.math.ln -import kotlin.math.roundToInt -import org.maplibre.android.geometry.LatLng as MLLatLng - -/** - * The one car screen: the live Vela map on the car display, plus the current - * maneuver card while navigating. State is the same [NavSession] singleton the - * phone drives - start a route on the phone and the car shows it; the phone - * keeps feeding location and speaking (audio routes to the car on its own). - * - * Rendering: the Car App Library gives templated apps a raw [SurfaceCallback] - * surface for the map. MapLibre wants a real View, so the renderer wraps the - * surface in a [VirtualDisplay] and shows a [Presentation] holding a plain - * [MapView] on it - the standard trick for surface-drawn car maps. Pan/zoom - * gestures from the car screen arrive as [SurfaceCallback.onScroll]/[onScale] - * and move the MapLibre camera by hand. - */ -class CarMapScreen( - carContext: CarContext, - private val navSession: NavSession, -) : Screen(carContext), DefaultLifecycleObserver { - - private val renderer = CarMapRenderer(carContext) - - init { - lifecycle.addObserver(this) - } - - override fun onCreate(owner: LifecycleOwner) { - carContext.getCarService(AppManager::class.java).setSurfaceCallback(renderer) - // Mirror nav state onto the car map + template. distinctUntilChanged via the - // snapshot key so per-fix updates only redraw what changed (the template call - // is cheap, but be polite to the host's update pipeline anyway). - var lastKey: List? = null - navSession.state - .onEach { s -> - renderer.setNav(s.navigating, s.route?.polyline) - val key = listOf(s.navigating, s.maneuverText, (s.nav.distanceToNextManeuver / 20).toInt()) - if (key != lastKey) { lastKey = key; invalidate() } - } - .launchIn(lifecycle.coroutineScope) - } - - override fun onDestroy(owner: LifecycleOwner) { - renderer.teardown() - } - - override fun onGetTemplate(): Template { - val s = navSession.state.value - val builder = NavigationTemplate.Builder() - .setActionStrip( - ActionStrip.Builder() - .addAction( - Action.Builder() - .setTitle(carContext.getString(R.string.mapscreen_recenter)) - .setOnClickListener { renderer.recenter() } - .build(), - ) - .addAction(Action.Builder().setTitle("+").setOnClickListener { renderer.zoomBy(1.0) }.build()) - .addAction(Action.Builder().setTitle("−").setOnClickListener { renderer.zoomBy(-1.0) }.build()) - .build(), - ) - .setPanModeListener { panning -> if (panning) renderer.stopFollowing() } - if (s.navigating && s.maneuverText.isNotBlank()) { - builder.setNavigationInfo( - RoutingInfo.Builder() - .setCurrentStep( - Step.Builder(s.maneuverText).build(), - carDistance(s.nav.distanceToNextManeuver), - ) - .build(), - ) - } - return builder.build() - } - - /** Meters → a car-host [Distance] in the user's display units (Google-style rounding). */ - private fun carDistance(meters: Double): Distance = if (Units.imperial.value) { - val feet = meters * 3.28084 - if (feet < 1000) { - val stepped = if (feet < 100) maxOf(10, (feet / 10).roundToInt() * 10) else (feet / 50).roundToInt() * 50 - Distance.create(stepped.toDouble(), Distance.UNIT_FEET) - } else { - Distance.create(meters / 1609.344, Distance.UNIT_MILES) - } - } else { - if (meters < 1000) { - Distance.create(maxOf(10.0, (meters / 10).roundToInt() * 10.0), Distance.UNIT_METERS) - } else { - Distance.create(meters / 1000.0, Distance.UNIT_KILOMETERS) - } - } -} - -/** - * Owns the car surface: virtual display + Presentation + MapView, the location - * puck (its own AOSP LocationManager listener - the car screen must work even - * when the phone UI is closed), the route line, and camera follow. - */ -private class CarMapRenderer(private val carContext: CarContext) : SurfaceCallback { - - private var virtualDisplay: VirtualDisplay? = null - private var presentation: Presentation? = null - private var mapView: MapView? = null - private var map: MapLibreMap? = null - private var style: Style? = null - - private var follow = true - private var framedOnce = false - private var lastFix: Location? = null - private var navigating = false - private var routePts: List? = null - - private val locListener = LocationListener { loc -> - lastFix = loc - applyFix(loc, animate = true) - } - - // ---- SurfaceCallback ---- - - override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { - val surface = surfaceContainer.surface ?: return - MapLibre.getInstance(carContext.applicationContext) - val dm = carContext.getSystemService() ?: return - val vd = dm.createVirtualDisplay( - "vela-car-map", - surfaceContainer.width, surfaceContainer.height, surfaceContainer.dpi, - surface, - DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, - ) - virtualDisplay = vd - val pres = Presentation(carContext, vd.display) - val mv = MapView(pres.context) - pres.setContentView(mv) - pres.show() - presentation = pres - mapView = mv - mv.onCreate(null) - mv.onStart() - mv.onResume() - mv.getMapAsync { m -> - map = m - m.setStyle(Style.Builder().fromUri(MapStyle.DEFAULT.uri)) { st -> - style = st - // Same runtime recolour as the phone map, keyed to the CAR's day/night. - runCatching { if (carContext.isDarkMode) applyDark(st) else applyLight(st) } - ensureOwnLayers(st) - pushRoute() - lastFix?.let { applyFix(it, animate = false) } - } - } - startLocation() - } - - override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) = teardown() - - override fun onScroll(distanceX: Float, distanceY: Float) { - follow = false - val m = map ?: return - val proj = m.projection - val center = proj.toScreenLocation(m.cameraPosition.target ?: return) - val moved = proj.fromScreenLocation(PointF(center.x + distanceX, center.y + distanceY)) - m.moveCamera(CameraUpdateFactory.newLatLng(moved)) - } - - override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { - if (scaleFactor <= 0f) return - map?.moveCamera(CameraUpdateFactory.zoomBy(ln(scaleFactor.toDouble()) / ln(2.0))) - } - - override fun onFling(velocityX: Float, velocityY: Float) { /* scroll deltas cover it */ } - - // ---- driven by the screen ---- - - fun setNav(nav: Boolean, polyline: List?) { - val routeChanged = polyline !== routePts - navigating = nav - routePts = polyline - if (routeChanged) pushRoute() - if (nav) follow = true - } - - fun recenter() { - follow = true - lastFix?.let { applyFix(it, animate = true) } - } - - fun stopFollowing() { follow = false } - - fun zoomBy(delta: Double) { - map?.animateCamera(CameraUpdateFactory.zoomBy(delta)) - } - - fun teardown() { - stopLocation() - map = null - style = null - mapView?.let { runCatching { it.onPause(); it.onStop(); it.onDestroy() } } - mapView = null - presentation?.let { runCatching { it.dismiss() } } - presentation = null - virtualDisplay?.let { runCatching { it.release() } } - virtualDisplay = null - } - - // ---- internals ---- - - private fun ensureOwnLayers(st: Style) { - st.addSource(GeoJsonSource(ROUTE_SOURCE)) - st.addSource(GeoJsonSource(PUCK_SOURCE)) - st.addLayer( - LineLayer(ROUTE_LAYER, ROUTE_SOURCE).withProperties( - PropertyFactory.lineColor("#4285F4"), - PropertyFactory.lineWidth(7f), - PropertyFactory.lineCap(Property.LINE_CAP_ROUND), - PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND), - ), - ) - st.addLayer( - CircleLayer(PUCK_HALO_LAYER, PUCK_SOURCE).withProperties( - PropertyFactory.circleRadius(16f), - PropertyFactory.circleColor("#4285F4"), - PropertyFactory.circleOpacity(0.2f), - ), - ) - st.addLayer( - CircleLayer(PUCK_LAYER, PUCK_SOURCE).withProperties( - PropertyFactory.circleRadius(7f), - PropertyFactory.circleColor("#4285F4"), - PropertyFactory.circleStrokeColor("#FFFFFF"), - PropertyFactory.circleStrokeWidth(2.5f), - ), - ) - } - - private fun pushRoute() { - val st = style ?: return - val src = st.getSourceAs(ROUTE_SOURCE) ?: return - val pts = routePts - if (pts.isNullOrEmpty()) { - src.setGeoJson(FeatureCollection.fromFeatures(emptyList())) - } else { - src.setGeoJson(LineString.fromLngLats(pts.map { Point.fromLngLat(it.lng, it.lat) })) - } - } - - private fun applyFix(loc: Location, animate: Boolean) { - val st = style ?: return - st.getSourceAs(PUCK_SOURCE)?.setGeoJson(Point.fromLngLat(loc.longitude, loc.latitude)) - if (!follow) return - val m = map ?: return - val target = MLLatLng(loc.latitude, loc.longitude) - val cam = if (navigating) { - CameraPosition.Builder() - .target(target) - .zoom(16.5) - .tilt(45.0) - .bearing(if (loc.hasBearing() && loc.speed > 1f) loc.bearing.toDouble() else m.cameraPosition.bearing) - .build() - } else { - val zoom = if (framedOnce) m.cameraPosition.zoom else 15.0 - framedOnce = true - CameraPosition.Builder().target(target).zoom(zoom).tilt(0.0).bearing(0.0).build() - } - if (animate) m.animateCamera(CameraUpdateFactory.newCameraPosition(cam), 900) - else m.moveCamera(CameraUpdateFactory.newCameraPosition(cam)) - } - - private fun startLocation() { - val lm = carContext.getSystemService() ?: return - // Same AOSP-only rule as the phone (never Fused). Permission was granted in the - // phone app; if it somehow isn't, the map just shows without a puck. - runCatching { - lm.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.let { lastFix = it } - lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 0f, locListener, Looper.getMainLooper()) - } - } - - private fun stopLocation() { - runCatching { carContext.getSystemService()?.removeUpdates(locListener) } - } - - private companion object { - const val ROUTE_SOURCE = "vela-car-route-src" - const val ROUTE_LAYER = "vela-car-route" - const val PUCK_SOURCE = "vela-car-loc-src" - const val PUCK_HALO_LAYER = "vela-car-loc-halo" - const val PUCK_LAYER = "vela-car-loc" - } -} diff --git a/app/src/main/java/app/vela/car/CarNightStyle.kt b/app/src/main/java/app/vela/car/CarNightStyle.kt new file mode 100644 index 00000000..f050a5a6 --- /dev/null +++ b/app/src/main/java/app/vela/car/CarNightStyle.kt @@ -0,0 +1,119 @@ +package app.vela.car + +import org.json.JSONObject + +/** + * The REAL dark map for the car at night: [darken] applies the same Liberty recolouring the phone's + * dark theme uses (`applyDark` in VelaMapView) - but as a STYLE-JSON transform, because the + * MapSnapshotter loads a style once and exposes no live [org.maplibre.android.maps.Style] to + * mutate. Every colour below is applyDark's, kept in lockstep by the comment there. + * + * Ladder in [CarMapRenderer]: night + style JSON in hand -> this transform (no tint filter); + * night + no JSON yet (MapFonts cache cold) -> the old darkening colour-filter as fallback; + * day -> untouched. A transform failure returns null and the caller falls back to the tint, so + * the worst case is exactly yesterday's behaviour. + */ +object CarNightStyle { + + private const val LAND = "#242f3e" + private const val WATER = "#17263c" + + private val MINOR_ROADS = setOf( + "road_minor", "road_secondary_tertiary", "road_link", "road_service_track", + "bridge_street", "bridge_secondary_tertiary", "bridge_link", "bridge_service_track", + ) + private val PRIMARY_ROADS = setOf("road_trunk_primary", "bridge_trunk_primary") + private val MOTORWAYS = setOf("road_motorway", "road_motorway_link", "bridge_motorway", "bridge_motorway_link") + private val CASINGS = setOf( + "road_motorway_casing", "road_motorway_link_casing", "road_trunk_primary_casing", + "road_secondary_tertiary_casing", "road_minor_casing", "road_link_casing", "road_service_track_casing", + "bridge_motorway_casing", "bridge_trunk_primary_casing", "bridge_secondary_tertiary_casing", + "bridge_street_casing", "bridge_link_casing", + ) + private val GREENS = setOf("park", "landcover_grass", "landcover_wood") + + /** Transform a Liberty style JSON into its night palette. Null on any parse failure. */ + fun darken(styleJson: String): String? = runCatching { + val root = JSONObject(styleJson) + val layers = root.optJSONArray("layers") ?: return null + for (i in 0 until layers.length()) { + val layer = layers.optJSONObject(i) ?: continue + val id = layer.optString("id") + val type = layer.optString("type") + val paint = layer.optJSONObject("paint") ?: JSONObject().also { layer.put("paint", it) } + when { + id == "background" -> paint.put("background-color", LAND) + id == "water" -> paint.put("fill-color", WATER) + id == "waterway_river" -> paint.put("line-color", WATER) + id == "park" -> { paint.put("fill-color", "#1c3326"); paint.put("fill-opacity", 0.7) } + id == "landcover_grass" -> { paint.put("fill-color", "#1c3326"); paint.put("fill-opacity", 0.5) } + id == "landcover_wood" -> { paint.put("fill-color", "#1a3023"); paint.put("fill-opacity", 0.6) } + id in MINOR_ROADS -> paint.put("line-color", "#49536a") + id in PRIMARY_ROADS -> paint.put("line-color", "#5e6a85") + id in MOTORWAYS -> paint.put("line-color", "#6f7a96") + id in CASINGS -> paint.put("line-color", LAND) // casings melt into the land, Google-style + id == "building" -> { + paint.put("fill-color", "#323f54") + paint.put("fill-outline-color", "#3f4e66") + layer.put("minzoom", 14); layer.put("maxzoom", 24) + } + id == "building-3d" -> { + paint.put("fill-extrusion-color", "#323f54") + paint.put("fill-extrusion-opacity", 0.9) + layer.put("minzoom", 16) + } + id == "landcover_wetland" -> { + paint.put("fill-color", "#1c3326"); paint.remove("fill-pattern") + } + id == "road_area_pattern" -> { + paint.put("fill-color", "#2a3546"); paint.remove("fill-pattern") + } + type == "symbol" -> { + paint.put("text-color", "#c3cad6") + paint.put("text-halo-color", "#1a2230") + paint.put("text-halo-width", if (id.startsWith("highway-name")) 1.9 else 1.1) + } + type == "fill" && id !in GREENS && + (id.startsWith("landuse") || id.startsWith("landcover")) -> { + paint.put("fill-color", "#2a3546"); paint.put("fill-opacity", 0.5) + } + } + } + root.toString() + }.getOrNull() + + /** Inject the phone's live-traffic raster overlay (same keyless Google tile, same 0.6 opacity, + * same below-the-first-symbol-layer placement as ensureTraffic on the live map) into a style + * JSON for the snapshotter. Null on parse failure - the caller just renders without traffic. */ + fun withTraffic(styleJson: String): String? = runCatching { + val root = JSONObject(styleJson) + val sources = root.optJSONObject("sources") ?: return null + val layers = root.optJSONArray("layers") ?: return null + sources.put( + "vela-traffic-src", + JSONObject().put("type", "raster").put("tileSize", 256) + .put("tiles", org.json.JSONArray().put(TRAFFIC_TILES)), + ) + val layer = JSONObject() + .put("id", "vela-traffic").put("type", "raster").put("source", "vela-traffic-src") + .put("paint", JSONObject().put("raster-opacity", 0.6)) + var at = layers.length() + for (i in 0 until layers.length()) { + if (layers.optJSONObject(i)?.optString("type") == "symbol") { at = i; break } + } + // JSONArray has no insert - rebuild with the layer at the symbol boundary. + val rebuilt = org.json.JSONArray() + for (i in 0 until layers.length()) { + if (i == at) rebuilt.put(layer) + rebuilt.put(layers.get(i)) + } + if (at == layers.length()) rebuilt.put(layer) + root.put("layers", rebuilt) + root.toString() + }.getOrNull() + + // The phone's TRAFFIC_TILES literal (VelaMapView) - keyless public Google traffic PNGs. + private const val TRAFFIC_TILES = + "https://www.google.com/maps/vt/pb=!1m4!1m3!1i{z}!2i{x}!3i{y}!2m9!1e2!2straffic!3i999999" + + "!4m2!1sincidents!2s1!4m2!1sincidents_text!2s1!3m8!2sen!3sus!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0!5m1!1e0" +} diff --git a/app/src/main/java/app/vela/car/CarVoiceSearch.kt b/app/src/main/java/app/vela/car/CarVoiceSearch.kt new file mode 100644 index 00000000..96934ccb --- /dev/null +++ b/app/src/main/java/app/vela/car/CarVoiceSearch.kt @@ -0,0 +1,151 @@ +package app.vela.car + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.media.CarAudioRecord +import androidx.car.app.model.Action +import androidx.car.app.model.CarIcon +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.lifecycleScope +import app.vela.R +import app.vela.ui.VoiceSearch +import app.vela.voice.VoiceResult +import app.vela.voice.WhisperRecognizer +import kotlinx.coroutines.launch + +/** + * In-car voice search: the car's microphone ([CarAudioRecord], Car API level 5+) feeding the same + * on-device Whisper pipeline as the phone mic ([WhisperRecognizer.listen] via a [WhisperRecognizer + * .PcmSource]). CarAudioRecord records 16 kHz mono PCM16 — exactly Whisper's input format — so the + * only adaptation is byte-pairs-to-shorts. + * + * The phone's tier-2 path (a RECOGNIZE_SPEECH provider activity) can't exist on a head unit, so the + * car mic shows whenever voice search is enabled and the on-device model is installed — regardless + * of the phone's engine pin, because on the car this is the only path there is. + */ +class CarVoiceSearch(private val carContext: CarContext, private val whisper: WhisperRecognizer) { + + /** Should the mic SHOW? Mirrors the phone: whenever the resolved mode is not NONE. NOT gated + * on the host API level - the landing templates once at session create, before the handshake + * reports carAppApiLevel (that gate silently hid the mic from exactly that screen). */ + fun available(): Boolean { + // Cached ~5 s: resolvedMode walks PackageManager (binder IPC) and the nav screen + // re-templates every state tick (review round 2). Settings flips land within a beat. + val now = android.os.SystemClock.elapsedRealtime() + if (now - availCheckedAt > 5_000) { + // LOCAL only: in SYSTEM mode Vela cannot start the host voice session (platform + // reserves it for Hey Google / the wheel button, which already routes into Vela via + // NAVIGATE intents), so a Vela mic button would be a dead button - the phone's own + // never-a-dead-button rule hides it (user requirement: no nonsense). + availCached = VoiceSearch.resolvedMode(carContext) == VoiceSearch.Mode.LOCAL + availCheckedAt = now + } + return availCached + } + private var availCached = false + private var availCheckedAt = 0L + + fun hasPermission(): Boolean = whisper.hasMicPermission() + + /** Ask the host to run the phone-side RECORD_AUDIO grant flow, then invalidate via [onDone]. + * Guarded: a host that can't service the request must degrade to a dead tap, not a crash card. */ + fun requestPermission(onDone: () -> Unit) { + runCatching { + carContext.requestPermissions(listOf(android.Manifest.permission.RECORD_AUDIO)) { _, _ -> onDone() } + } + } + + /** True while a capture is in flight. Screens read it for their loading state; a second mic tap + * flips it off, which [capture]'s cancelled() sees (= the phone's "done": transcribe what was heard). */ + var listening = false + private set + + /** The mic [Action] for a screen's action strip. One shared flow: tap to record (permission + * prompt first if needed), tap again to stop early; [onUpdate] fires on state changes the + * screen should re-template for, [onTranscript] gets the cleaned query. No-speech toasts. */ + fun micAction( + screen: Screen, + onUpdate: () -> Unit, + onTranscript: (String) -> Unit, + ): Action = + Action.Builder() + .setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_mic2)).build()) + .setOnClickListener { tap(screen, onUpdate, onTranscript) } + .build() + + /** LOCAL-mode only (see [available]): Vela's on-device Whisper through the car mic, phone-mic + * retry when the car mic is dead. A failure toasts and stops - speech never reroutes. */ + private fun tap(screen: Screen, onUpdate: () -> Unit, onTranscript: (String) -> Unit) { + if (VoiceSearch.resolvedMode(carContext) != VoiceSearch.Mode.LOCAL) return + if (listening) { listening = false; return } + if (!hasPermission()) { + requestPermission { screen.invalidate() } + return + } + listening = true + onUpdate() + // LONG: the short toast died ~2 s into a capture that legitimately runs up to 15 s, + // leaving no sign the car was still listening. + CarToast.makeText(carContext, R.string.voice_capture_listening, CarToast.LENGTH_LONG).show() + screen.lifecycleScope.launch { + carPeak = 0f + var result = capture { !listening } + // Car-mic ladder: retry ONCE on the PHONE's mic only when the car mic looks DEAD + // (no transcript AND essentially zero audio level) or failed outright - a genuine + // "tapped and said nothing" into a WORKING car mic must NOT start a second + // full-length capture with media paused (review round 2). + val carMicDead = result is VoiceResult.Failed || (result is VoiceResult.NoSpeech && carPeak < 0.02f) + if (listening && result !is VoiceResult.Text && carMicDead) { + CarToast.makeText(carContext, R.string.voice_capture_listening, CarToast.LENGTH_LONG).show() + result = whisper.listen(onLevel = {}, onListening = {}, cancelled = { !listening }) + } + listening = false + when (result) { + is VoiceResult.Text -> onTranscript(result.query) + else -> { + CarToast.makeText(carContext, R.string.car_voice_nothing, CarToast.LENGTH_SHORT).show() + onUpdate() + } + } + } + } + + /** Record one utterance from the car mic and transcribe it. Suspends until speech ends (or + * [cancelled] returns true / the 15 s cap); safe to call from the screen's lifecycleScope. */ + suspend fun capture(cancelled: () -> Boolean): VoiceResult { + if (runCatching { carContext.carAppApiLevel < 5 }.getOrDefault(true)) { + return VoiceResult.Failed(VoiceResult.Reason.AUDIO_INIT, "host below Car API 5 (no CarAudioRecord)") + } + val record = runCatching { CarAudioRecord.create(carContext) }.getOrNull() + ?: return VoiceResult.Failed(VoiceResult.Reason.AUDIO_INIT, "CarAudioRecord.create failed") + val bytes = ByteArray(WhisperRecognizer.VAD_WINDOW * 2 + 1) + val source = object : WhisperRecognizer.PcmSource { + // A carried low byte from an odd-length read: DROPPING it desynced the little-endian + // pairing for the rest of the capture (every later sample straddled two real samples - + // review finding); prepending it to the next read keeps the stream aligned. + private var carry = -1 + override fun start() = record.startRecording() + override fun read(buf: ShortArray, size: Int): Int { + var off = 0 + if (carry != -1) { bytes[0] = carry.toByte(); off = 1; carry = -1 } + val n = record.read(bytes, off, minOf(size * 2 - off, bytes.size - off)) + if (n < 0) return n + val total = off + n + val shorts = total / 2 + if (total % 2 == 1) carry = bytes[total - 1].toInt() and 0xFF + for (i in 0 until shorts) { + buf[i] = ((bytes[2 * i].toInt() and 0xFF) or (bytes[2 * i + 1].toInt() shl 8)).toShort() + } + return shorts + } + override fun stop() { + runCatching { record.stopRecording() } + } + } + return whisper.listen(onLevel = { if (it > carPeak) carPeak = it }, onListening = {}, cancelled = cancelled, source = source) + } + + // Peak level of the last car-mic capture - the dead-mic discriminator for the fallback ladder. + private var carPeak = 0f +} diff --git a/app/src/main/java/app/vela/car/LaneImage.kt b/app/src/main/java/app/vela/car/LaneImage.kt new file mode 100644 index 00000000..2ed2376f --- /dev/null +++ b/app/src/main/java/app/vela/car/LaneImage.kt @@ -0,0 +1,84 @@ +package app.vela.car + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import androidx.car.app.model.CarIcon +import androidx.car.app.navigation.model.Lane +import androidx.car.app.navigation.model.LaneDirection +import androidx.core.graphics.drawable.IconCompat +import app.vela.core.model.Lane as VelaLane + +/** + * Builds Android Auto lane-guidance from Vela's OSRM lane data: both the [Lane] metadata (which the + * host uses for accessibility/placement) AND a rendered lanes bitmap (which it displays). Valid lanes + * (the ones you can take for this maneuver) are drawn bright; the rest dimmed — Google's lane diagram. + */ +object LaneImage { + + private fun shape(ind: String): Int = when { + "uturn" in ind -> LaneDirection.SHAPE_U_TURN_LEFT + "sharp left" in ind -> LaneDirection.SHAPE_SHARP_LEFT + "sharp right" in ind -> LaneDirection.SHAPE_SHARP_RIGHT + "slight left" in ind -> LaneDirection.SHAPE_SLIGHT_LEFT + "slight right" in ind -> LaneDirection.SHAPE_SLIGHT_RIGHT + "left" in ind -> LaneDirection.SHAPE_NORMAL_LEFT + "right" in ind -> LaneDirection.SHAPE_NORMAL_RIGHT + "straight" in ind || "through" in ind -> LaneDirection.SHAPE_STRAIGHT + else -> LaneDirection.SHAPE_UNKNOWN + } + + /** Turn angle (degrees, 0 = straight up) for the arrow glyph. */ + private fun angle(ind: String): Float = when { + "uturn" in ind -> 180f + "sharp left" in ind -> -135f + "sharp right" in ind -> 135f + "slight left" in ind -> -40f + "slight right" in ind -> 40f + "left" in ind -> -90f + "right" in ind -> 90f + else -> 0f + } + + /** Car lanes + a rendered image, or null when there's no lane data. */ + fun build(lanes: List): Pair, CarIcon>? { + if (lanes.isEmpty() || lanes.size > 8) return null + val carLanes = lanes.map { vl -> + val b = Lane.Builder() + (vl.indications.ifEmpty { listOf("straight") }).forEach { + b.addDirection(LaneDirection.create(shape(it.lowercase()), vl.valid)) + } + b.build() + } + return carLanes to CarIcon.Builder(IconCompat.createWithBitmap(render(lanes))).build() + } + + private fun render(lanes: List): Bitmap { + val cell = 60; val h = 60 + val bmp = Bitmap.createBitmap((cell * lanes.size).coerceAtLeast(1), h, Bitmap.Config.ARGB_8888) + val c = Canvas(bmp) + lanes.forEachIndexed { i, vl -> + val cx = i * cell + cell / 2f + val color = if (vl.valid) Color.WHITE else Color.parseColor("#59616e") + // The valid indication drives the drawn arrow (for a through/turn lane, the turn wins). + val ind = (vl.indications.firstOrNull { angle(it.lowercase()) != 0f } ?: vl.indications.firstOrNull() ?: "straight").lowercase() + arrow(c, cx, h / 2f, angle(ind), color) + } + return bmp + } + + private fun arrow(c: Canvas, cx: Float, cy: Float, deg: Float, color: Int) { + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color; style = Paint.Style.FILL } + c.save() + c.rotate(deg, cx, cy) + val s = 15f + val head = Path().apply { + moveTo(cx, cy - s); lineTo(cx - s * 0.75f, cy); lineTo(cx + s * 0.75f, cy); close() + } + c.drawPath(head, paint) + c.drawRect(cx - 3f, cy - 2f, cx + 3f, cy + s + 5f, paint) // shaft + c.restore() + } +} diff --git a/app/src/main/java/app/vela/car/ManeuverMapper.kt b/app/src/main/java/app/vela/car/ManeuverMapper.kt new file mode 100644 index 00000000..d2d8e381 --- /dev/null +++ b/app/src/main/java/app/vela/car/ManeuverMapper.kt @@ -0,0 +1,126 @@ +package app.vela.car + +import androidx.car.app.model.Distance +import androidx.car.app.navigation.model.Maneuver +import androidx.car.app.navigation.model.RoutingInfo +import androidx.car.app.navigation.model.Step +import androidx.car.app.navigation.model.TravelEstimate +import app.vela.core.model.ManeuverType +import app.vela.core.model.Maneuver as VelaManeuver + +/** + * Maps Vela's routing model ([VelaManeuver]/[ManeuverType], metres, seconds) onto the + * `androidx.car.app.navigation` types a [androidx.car.app.navigation.model.NavigationTemplate] + * consumes. Pure + unit-testable — no Android Auto host needed. + * + * The one sharp edge the car API has: roundabout maneuvers built with an ENTER_AND_EXIT type + * REQUIRE a roundabout exit number or `Maneuver.Builder.build()` throws. [carManeuver] always + * supplies a concrete direction/side and a default exit number so no input ever throws. + */ +object ManeuverMapper { + + /** Right-hand traffic (US, Israel, most of the world) → counter-clockwise roundabouts; the + * car API needs a concrete CW/CCW type. A future locale hook can flip this for the UK etc. */ + private const val ROUNDABOUT_CCW = true + + fun carManeuver(m: VelaManeuver): Maneuver { + val type = when (m.type) { + ManeuverType.DEPART -> Maneuver.TYPE_DEPART + ManeuverType.ARRIVE -> Maneuver.TYPE_DESTINATION + ManeuverType.CONTINUE, ManeuverType.STRAIGHT, ManeuverType.UNKNOWN -> Maneuver.TYPE_STRAIGHT + ManeuverType.TURN_LEFT -> Maneuver.TYPE_TURN_NORMAL_LEFT + ManeuverType.TURN_RIGHT -> Maneuver.TYPE_TURN_NORMAL_RIGHT + ManeuverType.SLIGHT_LEFT -> Maneuver.TYPE_TURN_SLIGHT_LEFT + ManeuverType.SLIGHT_RIGHT -> Maneuver.TYPE_TURN_SLIGHT_RIGHT + ManeuverType.SHARP_LEFT -> Maneuver.TYPE_TURN_SHARP_LEFT + ManeuverType.SHARP_RIGHT -> Maneuver.TYPE_TURN_SHARP_RIGHT + ManeuverType.UTURN -> Maneuver.TYPE_U_TURN_LEFT + ManeuverType.MERGE -> Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED + ManeuverType.FORK_LEFT -> Maneuver.TYPE_FORK_LEFT + ManeuverType.FORK_RIGHT -> Maneuver.TYPE_FORK_RIGHT + ManeuverType.RAMP_LEFT -> Maneuver.TYPE_ON_RAMP_NORMAL_LEFT + ManeuverType.RAMP_RIGHT -> Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT + ManeuverType.KEEP_LEFT -> Maneuver.TYPE_KEEP_LEFT + ManeuverType.KEEP_RIGHT -> Maneuver.TYPE_KEEP_RIGHT + ManeuverType.ROUNDABOUT, ManeuverType.EXIT_ROUNDABOUT -> + if (ROUNDABOUT_CCW) Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW + else Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW + } + val b = Maneuver.Builder(type) + if (type == Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW || + type == Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW + ) { + b.setRoundaboutExitNumber(1) // exit count unknown from OSRM here; 1 keeps the builder valid + } + return b.build() + } + + /** A car [Step] for the current maneuver: the spoken cue + the road name being entered. */ + fun carStep(m: VelaManeuver): Step { + val cue: CharSequence = m.instruction.ifBlank { m.road?.takeIf { it.isNotBlank() } ?: m.ref?.takeIf { it.isNotBlank() } ?: " " } + return Step.Builder(cue) + .setManeuver(carManeuver(m)) + .apply { (m.road ?: m.ref)?.takeIf { it.isNotBlank() }?.let { setRoad(it as CharSequence) } } + // Lane guidance (Google's arrow diagram) — both the Lane metadata and a rendered image. + .apply { + runCatching { + LaneImage.build(m.lanes)?.let { (lanes, image) -> + lanes.forEach { addLane(it) } + setLanesImage(image) + } + } + } + .build() + } + + /** Metres → a car [Distance], rounded Google-style, in the user's unit system. */ + fun carDistance(meters: Double, imperial: Boolean): Distance { + return if (imperial) { + val feet = meters * 3.28084 + if (feet < 1000) Distance.create(roundStep(feet, if (feet >= 100) 50.0 else 10.0), Distance.UNIT_FEET) + else Distance.create(meters / 1609.344, Distance.UNIT_MILES) + } else { + if (meters < 1000) Distance.create(roundStep(meters, if (meters >= 100) 50.0 else 10.0), Distance.UNIT_METERS) + else Distance.create(meters / 1000.0, Distance.UNIT_KILOMETERS) + } + } + + // Round to the nearest step, but allow 0 (at the turn / arrival the host renders "Now"). The old + // coerceAtLeast(step) floored every distance to 10 ft/10 m so it never showed 0. + private fun roundStep(v: Double, step: Double): Double = (Math.round(v / step) * step).coerceAtLeast(0.0) + + /** The next-turn card for the [NavigationTemplate] — current step + distance to it, plus the + * following maneuver as the "then" step (Google's "then turn left" junction preview). */ + fun routingInfo(next: VelaManeuver?, then: VelaManeuver?, distanceToNext: Double, imperial: Boolean): RoutingInfo { + val b = RoutingInfo.Builder() + if (next == null) return b.setLoading(true).build() + b.setCurrentStep(carStep(next), carDistance(distanceToNext, imperial)) + if (then != null) runCatching { b.setNextStep(carStep(then)) } // "then …" preview + return b.build() + } + + /** A per-STEP [TravelEstimate] (distance + time to the NEXT maneuver) for Trip.addStep — the host's + * navigation data channel (cluster/rail). Time is a rough proportional estimate; the host mainly + * uses the distance. */ + fun stepEstimate(distanceToStepMeters: Double, secondsToStep: Double, nowEpochMillis: Long, imperial: Boolean): TravelEstimate { + val secs = secondsToStep.coerceAtLeast(0.0) + val arrivalMs = nowEpochMillis + (secs * 1000).toLong() + val arrival = androidx.car.app.model.DateTimeWithZone.create(arrivalMs, java.util.TimeZone.getDefault()) + return TravelEstimate.Builder(carDistance(distanceToStepMeters, imperial), arrival) + .setRemainingTimeSeconds(secs.toLong()) + .build() + } + + /** Destination ETA estimate: remaining distance, remaining time, and arrival wall-clock. */ + fun destinationEstimate(remainingMeters: Double, remainingSeconds: Double, nowEpochMillis: Long, imperial: Boolean): TravelEstimate { + // Clamp remaining ≥ 0 so a momentary NavEngine overshoot can't show a past arrival time. + val remaining = remainingSeconds.coerceAtLeast(0.0) + val arrivalMs = nowEpochMillis + (remaining * 1000).toLong() + val zone = java.util.TimeZone.getDefault() + val arrival = androidx.car.app.model.DateTimeWithZone.create(arrivalMs, zone) + return TravelEstimate.Builder(carDistance(remainingMeters, imperial), arrival) + .setRemainingTimeSeconds(remaining.toLong()) + .build() + } + +} diff --git a/app/src/main/java/app/vela/car/VelaCarAppService.kt b/app/src/main/java/app/vela/car/VelaCarAppService.kt index ea921392..ad98c9e0 100644 --- a/app/src/main/java/app/vela/car/VelaCarAppService.kt +++ b/app/src/main/java/app/vela/car/VelaCarAppService.kt @@ -1,39 +1,56 @@ package app.vela.car -import android.content.Intent import androidx.car.app.CarAppService import androidx.car.app.Session -import androidx.car.app.Screen import androidx.car.app.validation.HostValidator +import app.vela.car.screen.CarDeps +import app.vela.core.data.MapDataSource +import app.vela.core.data.ParkingStore +import app.vela.core.data.PlaceShortcutStore +import app.vela.core.data.RecentPlaceStore +import app.vela.core.data.RecentSearchStore +import app.vela.core.data.RouteEngine +import app.vela.core.data.SavedPlaceStore +import app.vela.core.location.LocationProvider import app.vela.core.nav.NavSession +import app.vela.core.voice.VoiceGuide +import app.vela.voice.WhisperRecognizer import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject /** - * Android Auto entry point. Vela registers as a NAVIGATION-category templated car - * app (see the manifest + automotive_app_desc.xml), so it shows up in the car - * launcher next to Google Maps. The car UI is deliberately thin: the phone app - * stays the brain (NavSession is the same singleton the phone feeds), the car - * screen renders the shared map + the active turn. + * Android Auto / AAOS entry point. A [CarAppService] is a bound Service, so Hilt (`@AndroidEntryPoint`) + * injects the same process-wide `:core` singletons the phone app uses — one [NavSession], one + * [LocationProvider], one [MapDataSource]. The car UI reuses them entirely (see [CarDeps]); it adds + * no routing/nav/voice logic of its own (voice already speaks from [NavSession] events). * - * Sideload note: a non-Play install only appears in AA after enabling - * "Unknown sources" in Android Auto's developer settings, same as any - * sideloaded car app. That also means the host is a developer-enabled one, so - * the permissive host validator below doesn't widen anything in practice. + * NB projected Android Auto requires Google Play Services on the phone, and Google allowlists + * NAVIGATION apps for production AA — so on a degoogled phone the car UI is reachable only via + * Android Auto developer mode. The realistic GMS-free target is embedded AAOS. See the plan/ROADMAP. */ @AndroidEntryPoint class VelaCarAppService : CarAppService() { @Inject lateinit var navSession: NavSession + @Inject lateinit var locationProvider: LocationProvider + @Inject lateinit var mapDataSource: MapDataSource + @Inject lateinit var recentPlaces: RecentPlaceStore + @Inject lateinit var recentSearches: RecentSearchStore + @Inject lateinit var savedPlaces: SavedPlaceStore + @Inject lateinit var shortcuts: PlaceShortcutStore + @Inject lateinit var voiceGuide: VoiceGuide + @Inject lateinit var routeEngine: RouteEngine + @Inject lateinit var whisper: WhisperRecognizer + @Inject lateinit var parkingStore: ParkingStore - // Accept whatever host the user's device runs (the real AA host, or a dev - // head unit). Template apps expose no data a hostile "host" could pull that - // it couldn't get from the phone screen itself. + // Allow ANY Android Auto / AAOS host to connect. Vela is sideloaded (never on Play) and must + // "just work" on whatever head unit / DHU a user plugs into — the + // standard release allowlist (hosts_allowlist_sample) rejects hosts it doesn't recognise, which + // manifested as the app appearing but refusing to open. The host-spoofing risk this guards against + // is negligible for a non-Play, self-distributed nav app. (2026-07-07) override fun createHostValidator(): HostValidator = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR - override fun onCreateSession(): Session = VelaCarSession(navSession) -} - -class VelaCarSession(private val navSession: NavSession) : Session() { - override fun onCreateScreen(intent: Intent): Screen = CarMapScreen(carContext, navSession) + override fun onCreateSession(): Session = VelaCarSession( + CarDeps(navSession, locationProvider, mapDataSource, recentPlaces, recentSearches, savedPlaces, shortcuts, voiceGuide, routeEngine, whisper, parkingStore), + ) } diff --git a/app/src/main/java/app/vela/car/VelaCarSession.kt b/app/src/main/java/app/vela/car/VelaCarSession.kt new file mode 100644 index 00000000..9c86d626 --- /dev/null +++ b/app/src/main/java/app/vela/car/VelaCarSession.kt @@ -0,0 +1,203 @@ +package app.vela.car + +import android.content.Intent +import android.net.Uri +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.ScreenManager +import androidx.car.app.Session +import app.vela.car.screen.RoutePreviewCarScreen +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import app.vela.car.screen.CarDeps +import app.vela.car.screen.MainCarScreen +import app.vela.core.model.LatLng +import app.vela.core.model.distanceTo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * One car projection session. Owns a location collector that feeds [app.vela.core.nav.NavSession] + * while the car UI is alive — so navigation runs off the car's own GPS feed, independent of the + * phone Activity/ViewModel (which is stopped during projection). + * + * Phase-1 minimal-arbiter note: the phone [app.vela.ui.map.MapViewModel] is the other feeder of the + * shared [NavSession] singleton. They don't run at once in projection (the phone UI is stopped), and + * `NavSession.onLocation`/`state.update` are atomic, so a stray double-feed can't crash — it's just + * redundant. A dedicated `NavCoordinator` arbiter (sole feeder/owner) is the clean follow-up. + */ +class VelaCarSession(private val deps: CarDeps) : Session(), DefaultLifecycleObserver { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var feedJob: Job? = null + + // Camera-alert bookkeeping: cams computed once per route identity (bundled DeFlock dataset, + // no network), each camera announced at most once per trip. + private var camRouteKey = 0 + private var camsOnRoute: List = emptyList() + private val camAnnounced = HashSet() + + init { + lifecycle.addObserver(this) + } + + override fun onCreate(owner: LifecycleOwner) { + // Vela voice pinned (or Auto with the model installed)? Warm the Whisper recognizer NOW, + // off-thread, so the first mic tap in the car listens immediately instead of spending the + // ~1-2 s native model load mid-tap (the phone warms it the same way at app start). + if (app.vela.ui.VoiceSearch.resolvedMode(carContext) == app.vela.ui.VoiceSearch.Mode.LOCAL) { + deps.whisper.warmUp() + } + // Feed live GPS into NavSession while the car session is alive. Mirrors the phone's fix gate: + // GPS fixes with accuracy ≤ 50 m drive guidance; coarser fixes are ignored for nav. + // Cancel any prior collector first so a re-delivered onCreate can't leak a second one. + feedJob?.cancel() + feedJob = scope.launch { + deps.locationProvider.updates().collect { loc -> + val gps = loc.provider == android.location.LocationManager.GPS_PROVIDER + if (gps && (!loc.hasAccuracy() || loc.accuracy <= 50f)) { + val speed = if (loc.hasSpeed()) loc.speed.toDouble() else null + val here = LatLng(loc.latitude, loc.longitude) + deps.navSession.onLocation(here, app.vela.ui.Units.imperial.value, speed) + maybeAlertCamera(here) + } + } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + feedJob?.cancel() + scope.cancel() + deps.stopMapRenderer() // the shared map renderer lives for the session; stop its collector here + } + + override fun onCreateScreen(intent: Intent): Screen { + // Launched via a navigation intent (assistant "navigate to X" / another app)? Go straight to + // the route preview for that destination; otherwise the normal landing screen. A free-text + // destination geocodes asynchronously and pushes the preview on top of the landing screen. + parseNavDest(intent)?.let { (dest, name) -> + return RoutePreviewCarScreen(carContext, deps, name, dest) + } + parseNavQuery(intent)?.let(::resolveFreeText) + // Resume: nav already running (started on the phone before plugging in, or the host + // recreated the session mid-drive)? Land straight in guidance. Pushed onto the landing + // screen (not returned as root) so End/arrival can pop back to something. arrived guards + // a push-pop flicker: an arrived-but-undismissed session must not bounce the screen. + val nav = deps.navSession.state.value + if (nav.navigating && !nav.arrived) { + // Dispatchers.Main (NOT the session scope's Main.immediate): immediate would run this + // push INLINE, before the library pushes the returned MainCarScreen as root, landing + // guidance UNDER the landing screen (review finding). A dispatched post runs after. + scope.launch(kotlinx.coroutines.Dispatchers.Main) { + carContext.getCarService(ScreenManager::class.java) + .push(app.vela.car.screen.ActiveNavCarScreen(carContext, deps)) + } + } + return MainCarScreen(carContext, deps) + } + + /** A navigation intent delivered while the app is already running (the host reuses the session). */ + override fun onNewIntent(intent: Intent) { + parseNavDest(intent)?.let { (dest, name) -> + carContext.getCarService(ScreenManager::class.java) + .push(RoutePreviewCarScreen(carContext, deps, name, dest)) + return + } + parseNavQuery(intent)?.let(::resolveFreeText) + } + + /** Spoken heads-up approaching a mapped license-plate camera (Settings -> Navigation -> + * Driving alerts; off by default). Cameras come from the BUNDLED DeFlock dataset filtered to + * the route corridor - instant and offline, the same source the phone's avoid-cameras + * re-rank prefers. Each camera speaks once per trip, within [CAM_ALERT_METERS] of the puck. */ + private fun maybeAlertCamera(here: LatLng) { + if (!app.vela.ui.DriveAlerts.cameras.value) return + val s = deps.navSession.state.value + val route = s.route ?: return + if (!s.navigating || s.arrived) return + val key = route.polyline.size * 31 + (route.polyline.firstOrNull()?.lat?.times(1e5)?.toInt() ?: 0) + if (key != camRouteKey) { + // Don't record the key until the dataset is actually loaded: caching emptyList while + // the bundled set is still parsing silenced alerts for the whole trip (review finding) + // - leaving the key stale makes the next fix retry until cameras are computable. + if (!app.vela.data.FlockCameras.isLoaded) return + camRouteKey = key + camAnnounced.clear() + camsOnRoute = runCatching { app.vela.data.FlockCameras.along(route.polyline).map { it.loc } }.getOrDefault(emptyList()) + } + camsOnRoute.forEachIndexed { i, cam -> + if (i !in camAnnounced && here.distanceTo(cam) <= CAM_ALERT_METERS) { + camAnnounced.add(i) + deps.voiceGuide.speak(carContext.getString(app.vela.R.string.car_camera_ahead), interrupt = false) + } + } + } + + /** Geocode a free-text NAVIGATE destination ("navigate to the nearest coffee shop") through the + * same [app.vela.core.data.MapDataSource.search] the search screens use, biased to the last + * known location, and land in the route preview for the top hit. Upstream punts these (the app + * just opens); resolving them is a fork addition. Misses get a toast, not silence. */ + private fun resolveFreeText(query: String) { + scope.launch { + val near = deps.locationProvider.lastKnown() + val hit = runCatching { deps.mapDataSource.search(query, near).places } + .getOrDefault(emptyList()).firstOrNull() + if (hit != null) { + carContext.getCarService(ScreenManager::class.java) + .push(RoutePreviewCarScreen(carContext, deps, hit.name, hit.location)) + } else { + CarToast.makeText( + carContext, + carContext.getString(app.vela.R.string.mapvm_no_results, query), + CarToast.LENGTH_LONG, + ).show() + } + } + } + + /** The free-text `q=` payload of a NAVIGATE `geo:` URI, when [parseNavDest] found no coords in + * it. URL-decoded ("coffee+shop" and %XX escapes arrive encoded). */ + private fun parseNavQuery(intent: Intent): String? { + if (intent.action != CarContext.ACTION_NAVIGATE) return null + val ssp = intent.data?.schemeSpecificPart ?: return null + val raw = ssp.substringAfter("q=", "").takeIf { it.isNotBlank() } ?: return null + return runCatching { java.net.URLDecoder.decode(raw, "UTF-8") }.getOrDefault(raw).trim().ifBlank { null } + } + + /** Parse an `androidx.car.app.action.NAVIGATE` intent's `geo:` URI into a destination. Handles + * `geo:lat,lng` and `geo:0,0?q=lat,lng(Label)`; a free-text `q=` goes through [parseNavQuery] + + * [resolveFreeText] (geocoded, then previewed) instead. NB `geo:` URIs are OPAQUE + * (non-hierarchical), so `Uri.getQueryParameter()` THROWS ("This isn't a hierarchical URI") — + * we parse the scheme-specific part by hand instead. */ + private fun parseNavDest(intent: Intent): Pair? { + if (intent.action != CarContext.ACTION_NAVIGATE) return null + val uri: Uri = intent.data ?: return null + val ssp = uri.schemeSpecificPart ?: return null + // Optional third numeric component (RFC 5870 altitude) is tolerated non-capturing, and + // ;u=/;crs= params are stripped below - matchEntire on the bare pair swallowed + // geo:lat,lng,alt and geo:lat,lng;u=35 NAVIGATE intents entirely (review finding). + val coordRe = Regex("""(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)(?:\s*,\s*-?\d+\.?\d*)?\s*(?:\((.*)\))?""") + // Prefer a `q=` payload (geo:0,0?q=lat,lng(Label)); else the bare `geo:lat,lng` coords. + val qVal = ssp.substringAfter("q=", "").takeIf { it.isNotBlank() } + val target = qVal ?: ssp.substringBefore('?') + // matchEntire, not find: "Store 12, 34th St" CONTAINS a coord-shaped substring but is a + // free-text query for the geocoder, not a destination at lat 12 lng 34. + coordRe.matchEntire(target.substringBefore(';').trim())?.let { m -> + val lat = m.groupValues[1].toDoubleOrNull(); val lng = m.groupValues[2].toDoubleOrNull() + // Reject the placeholder 0,0 only when it came from the bare coords (a real q= 0,0 is unheard of). + if (lat != null && lng != null && !(qVal == null && lat == 0.0 && lng == 0.0)) { + return LatLng(lat, lng) to m.groupValues[3].ifBlank { "Destination" } + } + } + return null + } + + private companion object { + const val CAM_ALERT_METERS = 400.0 + } +} diff --git a/app/src/main/java/app/vela/car/VoiceCommandRouter.kt b/app/src/main/java/app/vela/car/VoiceCommandRouter.kt new file mode 100644 index 00000000..1d03c8be --- /dev/null +++ b/app/src/main/java/app/vela/car/VoiceCommandRouter.kt @@ -0,0 +1,68 @@ +package app.vela.car + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.ScreenManager +import app.vela.R +import app.vela.car.screen.CarDeps +import app.vela.car.screen.CategoryResultsCarScreen +import app.vela.car.screen.RoutePreviewCarScreen +import app.vela.core.model.LatLng +import app.vela.core.model.ShortcutKind +import app.vela.core.voice.CarCommands + +/** Voice transcripts are interpreted HERE, at the calling screen, BEFORE anything is pushed: + * in-place commands (mute, end navigation) act and stay put, destination commands push their + * result screen directly, and only a real search pushes a results surface. The earlier design + * pushed the search screen first and parsed inside its init - which buried command results under + * a blank search screen and turned "end navigation" into a literal POI search (review round 2). */ +object VoiceCommandRouter { + + fun handle( + carContext: CarContext, + deps: CarDeps, + screenManager: ScreenManager, + transcript: String, + alongRoute: Boolean, + onEndNav: (() -> Unit)? = null, + ) { + when (val c = CarCommands.parse(transcript)) { + CarCommands.Command.Mute -> { deps.voiceGuide.muted = true; toast(carContext, R.string.car_mute) } + CarCommands.Command.Unmute -> { deps.voiceGuide.muted = false; toast(carContext, R.string.car_unmute) } + CarCommands.Command.EndNav -> + if (onEndNav != null) onEndNav() else toast(carContext, R.string.car_no_route) + CarCommands.Command.GoHome -> shortcut(carContext, deps, screenManager, ShortcutKind.HOME, transcript, alongRoute) + CarCommands.Command.GoWork -> shortcut(carContext, deps, screenManager, ShortcutKind.WORK, transcript, alongRoute) + CarCommands.Command.FindMyCar -> { + val spot = deps.parkingStore.current() + if (spot != null) { + screenManager.push( + RoutePreviewCarScreen( + carContext, deps, + carContext.getString(R.string.map_parking_find), + LatLng(spot.lat, spot.lng), + ), + ) + } else toast(carContext, R.string.map_parking_no_fix) + } + is CarCommands.Command.Search -> + screenManager.push(CategoryResultsCarScreen(carContext, deps, c.query, c.query, alongRoute)) + } + } + + private fun shortcut( + carContext: CarContext, + deps: CarDeps, + screenManager: ScreenManager, + kind: ShortcutKind, + fallback: String, + alongRoute: Boolean, + ) { + val sc = deps.shortcuts.get(kind) + if (sc != null) screenManager.push(RoutePreviewCarScreen(carContext, deps, sc.name, sc.location)) + else screenManager.push(CategoryResultsCarScreen(carContext, deps, fallback, fallback, alongRoute)) + } + + private fun toast(carContext: CarContext, res: Int) = + CarToast.makeText(carContext, res, CarToast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/app/vela/car/screen/ActiveNavCarScreen.kt b/app/src/main/java/app/vela/car/screen/ActiveNavCarScreen.kt new file mode 100644 index 00000000..3d05f53f --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/ActiveNavCarScreen.kt @@ -0,0 +1,290 @@ +package app.vela.car.screen + +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.Alert +import androidx.car.app.model.AlertCallback +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarText +import androidx.car.app.model.Template +import androidx.car.app.navigation.NavigationManager +import androidx.car.app.navigation.NavigationManagerCallback +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import app.vela.car.ManeuverMapper +import app.vela.service.NavigationService +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** The active-guidance car screen: renders [NavSession] state into a [NavigationTemplate] + * (route map surface + next-maneuver card + destination ETA). Voice already speaks from NavSession. + * "End" stops the session; "Mute" toggles the voice; a faster-route offer is a tappable action. */ +class ActiveNavCarScreen(carContext: CarContext, private val deps: CarDeps) : + Screen(carContext), DefaultLifecycleObserver { + + // Leave the nav screen at most once — on arrival OR when nav stops (from here or elsewhere). + private var left = false + private val voice = app.vela.car.CarVoiceSearch(carContext, deps.whisper) + // One head-up alert per faster-route offer (keyed by the candidate's duration+distance; a new + // offer alerts again, the same one doesn't re-pop every state tick). + private var alertedFasterKey = 0L + + private val navManager by lazy { carContext.getCarService(NavigationManager::class.java) } + // The host only renders the RoutingInfo turn card once the app DECLARES it is navigating via + // NavigationManager.navigationStarted() (the DestinationTravelEstimate shows without it — which + // is why the ETA appeared but the next-turn banner never did). navigationStarted/Ended must be + // balanced (a second navigationStarted() before navigationEnded() throws), so gate on this flag. + private var navDeclared = false + private var callbackSet = false + + init { + // Marker so the terminal-state handler can peel back to THIS screen no matter what got + // pushed on top mid-drive (along-route search, stops, categories) - a blind pop() removed + // the covering screen instead and stranded a dead guidance screen (review finding). + marker = NAV_MARKER + lifecycle.addObserver(this) + // Re-render on each nav-state change (state emits ~1 Hz during guidance); pop when the trip + // ends. Voice already announces the arrival, so returning to the landing screen is enough. + lifecycleScope.launch { + deps.navSession.state.collect { s -> + if (!left && (s.arrived || !s.navigating)) { + left = true + endNavDeclaration() + // Peel any covering screens first, then remove guidance itself. + runCatching { screenManager.popTo(NAV_MARKER) } + screenManager.pop() + // Arrival (not a manual End): land on the arrival card so the driver can save + // the parking spot where the car now sits - the phone offers the same. + if (s.arrived) { + screenManager.push(ArrivalCarScreen(carContext, deps, s.destinationLabel)) + } + } else { + declareNavStartedIfNeeded() + maybeAlertFasterRoute() + invalidate() + } + } + } + } + + /** Set the required callback (once) and declare navigation started so the host renders the turn + * card. The callback MUST be set before navigationStarted(); the host calls onStopNavigation() + * when it (not the user's End button) wants nav to stop. */ + private fun declareNavStartedIfNeeded() { + if (!callbackSet) { + runCatching { + navManager.setNavigationManagerCallback(object : NavigationManagerCallback { + override fun onStopNavigation() { stopNav() } + override fun onAutoDriveEnabled() {} + }) + }.onSuccess { callbackSet = true } + } + if (callbackSet && !navDeclared && deps.navSession.state.value.navigating) { + runCatching { navManager.navigationStarted() }.onSuccess { navDeclared = true } + } + } + + private fun endNavDeclaration() { + if (navDeclared) { + runCatching { navManager.navigationEnded() } + navDeclared = false + } + } + + override fun onStop(owner: LifecycleOwner) { + // A non-map screen (search, categories, stops, details) is covering us - stop posting + // frames under the host's overlay (they tear through it). onStart's start() resumes. + deps.mapRenderer(carContext).pause() + } + + override fun onStart(owner: LifecycleOwner) { + // Claim the surface with the SHARED renderer + nav mode (route + puck, follow). Never clear it + // on stop and never stop the collector here — the shared renderer lives for the session (a stop + // here would race the next screen's start); VelaCarSession stops it on destroy. + val renderer = deps.mapRenderer(carContext) + renderer.follow() // nav mode: draws from NavSession.navigating + carContext.getCarService(AppManager::class.java).setSurfaceCallback(renderer) + renderer.start() + // Declare navigation to the host now (nav is active by the time this screen shows) so the + // first template already carries a rendered turn card, not just the ETA. + declareNavStartedIfNeeded() + } + + override fun onDestroy(owner: LifecycleOwner) { + // Balance the navigationStarted() declaration if the screen tears down without a terminal + // state pass (e.g. the whole car session ends) — leaving it "started" would wedge the host's + // nav state for the next session. onDestroy (not onStop) so a transient cover during nav + // doesn't prematurely retract the turn card. + endNavDeclaration() + } + + override fun onGetTemplate(): Template { + val s = deps.navSession.state.value + val imperial = app.vela.ui.Units.imperial.value + val next = s.route?.maneuvers?.getOrNull(s.nav.stepIndex) + val then = s.route?.maneuvers?.getOrNull(s.nav.stepIndex + 1) // "then …" junction preview + + val info = ManeuverMapper.routingInfo(next, then, s.nav.distanceToNextManeuver, imperial) + val estimate = ManeuverMapper.destinationEstimate( + s.remainingDistance, s.remainingDuration, System.currentTimeMillis(), imperial, + ) + + // Feed the host's navigation DATA channel (a Trip), separate from the template's RoutingInfo. + // Gearhead logged "No corresponding nav client source / Unable to send navigation status" + // without it — on this Honda the turn card appears gated on the Trip data, not just the + // template. Best-effort; guarded so a build/host hiccup can't blank the template. + if (navDeclared && next != null) { + runCatching { + val secsToStep = if (s.remainingDistance > 0.0) + s.remainingDuration * (s.nav.distanceToNextManeuver / s.remainingDistance) else 0.0 + val trip = androidx.car.app.navigation.model.Trip.Builder() + .addStep( + ManeuverMapper.carStep(next), + ManeuverMapper.stepEstimate(s.nav.distanceToNextManeuver, secsToStep, System.currentTimeMillis(), imperial), + ) + .addDestination( + androidx.car.app.navigation.model.Destination.Builder() + .setName(s.destinationLabel.ifBlank { "Destination" }) + .build(), + estimate, + ) + .build() + navManager.updateTrip(trip) + } + } + + val strip = ActionStrip.Builder() + // In-drive search-along-route: the mic and a search action (both push the corridor-filtered + // SearchCarScreen; a pick becomes a stop). Icon-only - the strip's text budget stays on + // Faster/Mute/End. The mic's transcript skips the keyboard entirely. + // Same preference-law mic as the landing screen, along-route flavored. + if (voice.available()) { + strip.addAction( + voice.micAction( + this, ::invalidate, + onTranscript = { q -> + app.vela.car.VoiceCommandRouter.handle(carContext, deps, screenManager, q, alongRoute = true, onEndNav = ::stopNav) + }, + ), + ) + } + strip.addAction( + carAction(android.R.drawable.ic_menu_search) { + screenManager.push(SearchCarScreen(carContext, deps, alongRoute = true)) + }, + ) + // A faster-route offer (when present) takes the first slot as a tappable "Faster −N min". + val faster = s.fasterRoute + if (faster != null && s.fasterSavingSeconds >= 60) { + val mins = (s.fasterSavingSeconds / 60).roundToInt().coerceAtLeast(1) + strip.addAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_faster_route, mins)) + .setOnClickListener { deps.navSession.acceptFasterRoute(); invalidate() } + .build(), + ) + } else { + val muted = deps.voiceGuide.muted + strip.addAction( + Action.Builder() + .setTitle(carContext.getString(if (muted) app.vela.R.string.car_unmute else app.vela.R.string.car_mute)) + .setOnClickListener { deps.voiceGuide.muted = !deps.voiceGuide.muted; invalidate() } + .build(), + ) + } + strip.addAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_end)) + // A background colour is ONLY allowed on a PRIMARY action — without FLAG_PRIMARY the + // host throws "Background color can only be set for primary actions" building the strip. + .setFlags(Action.FLAG_PRIMARY) + .setBackgroundColor(CarColor.RED) + .setOnClickListener { stopNav() } + .build(), + ) + + // Google-style map controls: recenter (re-follow the puck) + zoom in/out; plus the stop + // manager while the trip actually has stops (max 4 actions on this strip). + val mapStripB = ActionStrip.Builder() + .addAction(carAction(android.R.drawable.ic_menu_mylocation) { deps.mapRenderer(carContext).follow() }) + .addAction(carAction(android.R.drawable.ic_menu_add) { deps.mapRenderer(carContext).zoomBy(1.0) }) + .addAction(carAction(android.R.drawable.ic_menu_revert) { deps.mapRenderer(carContext).zoomBy(-1.0) }) + if (deps.navSession.remainingStops().isNotEmpty()) { + mapStripB.addAction(carAction(android.R.drawable.ic_menu_agenda) { screenManager.push(StopsCarScreen(carContext, deps)) }) + } + val mapStrip = mapStripB.build() + + return NavigationTemplate.Builder() + .setNavigationInfo(info) + .setDestinationTravelEstimate(estimate) + .setActionStrip(strip.build()) + .setMapActionStrip(mapStrip) + .build() + } + + private fun carAction(iconRes: Int, onClick: () -> Unit): Action = + Action.Builder() + .setIcon( + androidx.car.app.model.CarIcon.Builder( + androidx.core.graphics.drawable.IconCompat.createWithResource(carContext, iconRes), + ).build(), + ) + .setOnClickListener(onClick) + .build() + + /** Surface a faster-route offer as a HEAD-UP alert (Car API 5+), not just a strip button the + * driver never notices. Accept/dismiss mirror the strip action; the strip button stays as the + * fallback (and for hosts below API 5). One alert per distinct candidate. */ + private fun maybeAlertFasterRoute() { + val s = deps.navSession.state.value + val faster = s.fasterRoute ?: return + if (s.fasterSavingSeconds < 60) return + if (runCatching { carContext.carAppApiLevel < 5 }.getOrDefault(true)) return + val key = (faster.durationSeconds.toLong() shl 20) xor faster.distanceMeters.toLong() + if (key == alertedFasterKey) return + alertedFasterKey = key + val mins = (s.fasterSavingSeconds / 60).roundToInt().coerceAtLeast(1) + runCatching { + val alert = Alert.Builder( + ALERT_FASTER_ID, + CarText.create(carContext.getString(app.vela.R.string.car_faster_route, mins)), + ALERT_DURATION_MS, + ) + .addAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_go)) + .setOnClickListener { deps.navSession.acceptFasterRoute(); invalidate() } + .build(), + ) + .addAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.nav_done)) + .setOnClickListener { deps.navSession.dismissFasterRoute(); invalidate() } + .build(), + ) + .setCallback(object : AlertCallback { + override fun onCancel(reason: Int) {} + override fun onDismiss() {} + }) + .build() + carContext.getCarService(AppManager::class.java).showAlert(alert) + } + } + + private fun stopNav() { + // Only stop — the state collector observes navigating=false and pops once (no double-pop). + deps.navSession.stop() + runCatching { NavigationService.stop(carContext.applicationContext) } + } + + private companion object { + const val NAV_MARKER = "active-nav" + const val ALERT_FASTER_ID = 1 + const val ALERT_DURATION_MS = 15_000L + } +} diff --git a/app/src/main/java/app/vela/car/screen/ArrivalCarScreen.kt b/app/src/main/java/app/vela/car/screen/ArrivalCarScreen.kt new file mode 100644 index 00000000..d0c81099 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/ArrivalCarScreen.kt @@ -0,0 +1,56 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Template +import app.vela.R +import app.vela.core.model.ParkedSpot + +/** The end-of-trip card: "You've arrived", with a one-tap "Save parking spot" for where the car + * now sits (the phone's Park feature reads the same [app.vela.core.data.ParkingStore], so the + * spot shows up there and on the landing screen's "Find my car" row). Dismissing either way + * fully stops the nav session - mirroring the phone's arrival-card dismiss. */ +class ArrivalCarScreen( + carContext: CarContext, + private val deps: CarDeps, + private val destLabel: String, +) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val builder = MessageTemplate.Builder(destLabel.ifBlank { carContext.getString(R.string.nav_arrived) }) + .setTitle(carContext.getString(R.string.nav_arrived)) + .setHeaderAction(Action.BACK) + val loc = deps.locationProvider.lastKnown() + if (loc != null) { + builder.addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.map_parking_save)) + .setOnClickListener { + deps.parkingStore.save(ParkedSpot(loc.lat, loc.lng, System.currentTimeMillis())) + CarToast.makeText(carContext, R.string.map_parking_saved, CarToast.LENGTH_SHORT).show() + finishTrip() + } + .build(), + ) + } + builder.addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.nav_done)) + .setFlags(Action.FLAG_PRIMARY) + .setOnClickListener { finishTrip() } + .build(), + ) + return builder.build() + } + + private fun finishTrip() { + // Clear the arrived session (the phone's arrival-card dismiss does the same via clearRoute) + // so the landing screen's resume logic never sees a stale arrived=true state. + deps.navSession.stop() + runCatching { app.vela.service.NavigationService.stop(carContext.applicationContext) } + screenManager.pop() + } +} diff --git a/app/src/main/java/app/vela/car/screen/CarDeps.kt b/app/src/main/java/app/vela/car/screen/CarDeps.kt new file mode 100644 index 00000000..f0346177 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/CarDeps.kt @@ -0,0 +1,39 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import app.vela.car.CarMapRenderer +import app.vela.core.data.MapDataSource +import app.vela.core.data.ParkingStore +import app.vela.core.data.PlaceShortcutStore +import app.vela.core.data.RecentPlaceStore +import app.vela.core.data.RecentSearchStore +import app.vela.core.data.RouteEngine +import app.vela.core.data.SavedPlaceStore +import app.vela.core.location.LocationProvider +import app.vela.core.nav.NavSession +import app.vela.core.voice.VoiceGuide +import app.vela.voice.WhisperRecognizer + +/** The `:core` singletons the car screens share (all the same instances the phone app uses). */ +data class CarDeps( + val navSession: NavSession, + val locationProvider: LocationProvider, + val mapDataSource: MapDataSource, + val recentPlaces: RecentPlaceStore, + val recentSearches: RecentSearchStore, // car searches join the same history the phone shows + val savedPlaces: SavedPlaceStore, + val shortcuts: PlaceShortcutStore, + val voiceGuide: VoiceGuide, + val routeEngine: RouteEngine, // for the speed-limit badge (offline graphs' max_speed) + val whisper: WhisperRecognizer, // in-car voice search (the same on-device model as the phone mic) + val parkingStore: ParkingStore, // arrival "save parking" + the landing screen's Find my car row +) { + // ONE shared map renderer for the whole car session. Per-screen renderer instances DON'T work: + // swapping the surface callback to a new instance doesn't re-deliver onSurfaceAvailable, so the + // new renderer never gets the surface and the map freezes. All screens use this same instance + // (set as the callback in each onStart) and just switch its mode (browse / preview / nav). + private var sharedRenderer: CarMapRenderer? = null + fun mapRenderer(ctx: CarContext): CarMapRenderer = + sharedRenderer ?: CarMapRenderer(ctx, locationProvider, navSession, routeEngine).also { sharedRenderer = it } + fun stopMapRenderer() { sharedRenderer?.stop() } +} diff --git a/app/src/main/java/app/vela/car/screen/CategoriesCarScreen.kt b/app/src/main/java/app/vela/car/screen/CategoriesCarScreen.kt new file mode 100644 index 00000000..7184e51a --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/CategoriesCarScreen.kt @@ -0,0 +1,56 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.CarIcon +import androidx.car.app.model.GridItem +import androidx.car.app.model.GridTemplate +import androidx.car.app.model.ItemList +import androidx.car.app.model.Template +import androidx.core.graphics.drawable.IconCompat +import app.vela.R + +/** One-tap category search - the phone's four along-route categories plus two car-first ones + * (Parking, EV charging), all under the phone chips' dual-purpose-literal rule: the LABEL + * localizes, the search QUERY stays the stable English key. From the landing screen a tap searches nearby; from active nav + * ([alongRoute]) results come corridor-filtered and a pick adds a stop. */ +class CategoriesCarScreen( + carContext: CarContext, + private val deps: CarDeps, + private val alongRoute: Boolean = false, +) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val list = ItemList.Builder() + listOf( + Triple(R.string.cat_gas, "Gas", R.drawable.ic_car_gas), + Triple(R.string.cat_food, "Food", R.drawable.ic_car_food), + Triple(R.string.cat_coffee, "Coffee", R.drawable.ic_car_coffee), + Triple(R.string.cat_groceries, "Groceries", R.drawable.ic_car_grocery), + Triple(R.string.cat_parking, "Parking", R.drawable.ic_car_parking), + Triple(R.string.cat_charging, "EV charging", R.drawable.ic_car_charging), + ).forEach { (labelRes, query, iconRes) -> + list.addItem( + GridItem.Builder() + .setTitle(carContext.getString(labelRes)) + // IMAGE_TYPE_LARGE: ICON lets the host TINT the image monochrome - the colored + // circle tiles rendered as grey blobs (head-unit report). LARGE keeps our colors. + .setImage( + CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build(), + GridItem.IMAGE_TYPE_LARGE, + ) + // Straight to RESULTS - not a search box with the query typed in (user feedback). + .setOnClickListener { + screenManager.push(CategoryResultsCarScreen(carContext, deps, carContext.getString(labelRes), query, alongRoute)) + } + .build(), + ) + } + return GridTemplate.Builder() + .setTitle(carContext.getString(R.string.car_categories)) + .setHeaderAction(Action.BACK) + .setSingleList(list.build()) + .build() + } +} diff --git a/app/src/main/java/app/vela/car/screen/CategoryResultsCarScreen.kt b/app/src/main/java/app/vela/car/screen/CategoryResultsCarScreen.kt new file mode 100644 index 00000000..5488fa33 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/CategoryResultsCarScreen.kt @@ -0,0 +1,84 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.lifecycle.lifecycleScope +import app.vela.core.data.RouteCorridor +import app.vela.core.model.Place +import app.vela.core.nav.NavSession +import kotlinx.coroutines.launch + +/** Category tap OR voice transcript -> RESULTS, directly ("searching..." spinner, then the list) - no search box with "Food" typed into it (user feedback: + * Google Maps AA goes straight to the list). Same fetch + pick semantics as SearchCarScreen's + * along-route mode: nearby when browsing, corridor-filtered add-a-stop while navigating. */ +class CategoryResultsCarScreen( + carContext: CarContext, + private val deps: CarDeps, + private val title: String, // category label or a voice transcript - shown as the list title + private val query: String, // what actually gets searched + private val alongRoute: Boolean, +) : Screen(carContext) { + + private var results: List = emptyList() + private var loading = true + private var fetched = false + + override fun onGetTemplate(): Template { + if (!fetched) { fetched = true; fetch() } + val builder = ListTemplate.Builder() + .setTitle(title) + .setHeaderAction(Action.BACK) + if (loading) return builder.setLoading(true).build() + val list = ItemList.Builder() + if (results.isEmpty()) { + list.setNoItemsMessage(carContext.getString(app.vela.R.string.mapvm_no_results, title)) + } else { + results.take(6).forEach { p -> + list.addItem( + Row.Builder() + .setTitle(p.name) + .apply { p.address?.let { addText(it) } } + .setOnClickListener { onPick(p) } + .build(), + ) + } + } + return builder.setSingleList(list.build()).build() + } + + private fun fetch() { + lifecycleScope.launch { + val route = if (alongRoute) deps.navSession.state.value.route?.polyline else null + results = runCatching { + if (route != null && route.size >= 2) { + RouteCorridor.alongRoute(deps.mapDataSource.search(query, route[route.size / 2]).places, route) + } else { + deps.mapDataSource.search(query, deps.locationProvider.lastKnown()).places + } + }.getOrDefault(emptyList()) + loading = false + invalidate() + } + } + + private fun onPick(p: Place) { + val nav = deps.navSession.state.value + if (alongRoute && nav.navigating && !nav.arrived) { + val loc = deps.locationProvider.lastKnown() + if (loc != null) { + deps.navSession.addStop(NavSession.NavStop(p.location, p.name), loc) + CarToast.makeText(carContext, app.vela.R.string.car_stop_added, CarToast.LENGTH_SHORT).show() + // Peel Results + Categories back to guidance (ActiveNavCarScreen's marker). + runCatching { screenManager.popTo("active-nav") } + return + } + } + screenManager.push(RoutePreviewCarScreen(carContext, deps, p.name, p.location, p)) + } +} diff --git a/app/src/main/java/app/vela/car/screen/MainCarScreen.kt b/app/src/main/java/app/vela/car/screen/MainCarScreen.kt new file mode 100644 index 00000000..fd389f25 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/MainCarScreen.kt @@ -0,0 +1,197 @@ +package app.vela.car.screen + +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.ItemList +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import android.text.SpannableString +import android.text.Spanned +import androidx.car.app.model.DurationSpan +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import androidx.car.app.navigation.model.PlaceListNavigationTemplate +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import app.vela.car.CarMapRenderer +import app.vela.core.model.LatLng +import app.vela.core.model.ShortcutKind + +/** + * Car landing screen: Home/Work shortcuts + recent + saved destinations, and a Search action. + * Tapping a row previews a route to it ([RoutePreviewCarScreen]). Reuses [CarDeps] stores. + * + * Owns a [CarMapRenderer] so the landing map is a LIVE, clean browse map centred on you — otherwise + * the surface keeps the previous nav screen's final frame and the finished trip's route lingers here. + */ +class MainCarScreen(carContext: CarContext, private val deps: CarDeps) : + Screen(carContext), DefaultLifecycleObserver { + + private val voice = app.vela.car.CarVoiceSearch(carContext, deps.whisper) + + // Live drive-time subtitles for the Home/Work rows, fetched once per screen instance (a cheap + // pair of directions calls); rows render plain until they land, then invalidate() fills them in. + private val etaSecs = HashMap() + private var etaFetched = false + + init { lifecycle.addObserver(this) } + + override fun onStop(owner: LifecycleOwner) { + // A non-map screen (search, categories, stops, details) is covering us - stop posting + // frames under the host's overlay (they tear through it). onStart's start() resumes. + deps.mapRenderer(carContext).pause() + } + + override fun onStart(owner: LifecycleOwner) { + // Shared renderer, browse mode (clean live map, no route). Never clear the callback / stop the + // collector on transitions — the shared renderer lives for the session (VelaCarSession stops it). + val renderer = deps.mapRenderer(carContext) + renderer.follow() + carContext.getCarService(AppManager::class.java).setSurfaceCallback(renderer) + renderer.start() + } + + override fun onGetTemplate(): Template { + fetchEtasOnce() + val list = ItemList.Builder() + + // PlaceListNavigationTemplate caps the list at MAX_ROWS (6) — adding more THROWS. Build the + // candidates (Home, Work, recents, saved) and take the first 6, de-duped by location — + // minus one when the parked-car row below needs its slot. + val parked = deps.parkingStore.current() + val rows = buildList { + deps.shortcuts.get(ShortcutKind.HOME)?.let { add(it.name to it.location) } + deps.shortcuts.get(ShortcutKind.WORK)?.let { add(it.name to it.location) } + deps.recentPlaces.recent().forEach { add(it.place.name to it.place.location) } + deps.savedPlaces.saved().forEach { add(it.name to it.location) } + }.distinctBy { it.second.lat to it.second.lng } + .take(MAX_ROWS - 1 - (if (parked != null) 1 else 0)) // categories row + optional parked row + + // Categories lives IN the list (Google Maps-style row with a colored tile): the landing + // template's strip renders only two actions on real hosts, and a third silently pushed the + // MIC off (head-unit report) - the strip stays mic + Search. + list.addItem( + Row.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_categories)) + .setImage(icon(app.vela.R.drawable.ic_car_categories_tile), Row.IMAGE_TYPE_SMALL) + .setBrowsable(true) + .setOnClickListener { screenManager.push(CategoriesCarScreen(carContext, deps)) } + .build(), + ) + rows.forEach { (name, loc) -> list.addItem(destRow(name, loc)) } + // Parked car: when a spot is saved (phone Park or the car's arrival card), one row routes + // back to it. After the destination rows so Home/Work keep their muscle-memory slots. + parked?.let { spot -> + list.addItem( + Row.Builder() + .setTitle(carContext.getString(app.vela.R.string.map_parking_find)) + .setImage(icon(app.vela.R.drawable.ic_car_parking), Row.IMAGE_TYPE_SMALL) + .setBrowsable(true) + .setOnClickListener { + screenManager.push( + RoutePreviewCarScreen( + carContext, deps, + carContext.getString(app.vela.R.string.map_parking_find), + app.vela.core.model.LatLng(spot.lat, spot.lng), + ), + ) + } + .build(), + ) + } + if (rows.isEmpty() && parked == null) { + list.setNoItemsMessage(carContext.getString(app.vela.R.string.car_no_destinations)) + } + + val search = Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_search)) + .setIcon(icon(android.R.drawable.ic_menu_search)) + .setOnClickListener { screenManager.push(SearchCarScreen(carContext, deps)) } + .build() + // Voice search straight from the landing screen - no need to open Search first. The + // transcript opens SearchCarScreen with the query already searched. + // Mic LAST: when this host runs out of strip slots it keeps the TAIL of the list (head-unit + // evidence across three builds - the mic sat first and was the one dropped every time). + val strip = ActionStrip.Builder() + strip.addAction(search) + // The mic honors the phone's voice-engine preference, NO exceptions (user requirement): + // resolved LOCAL runs Vela's on-device Whisper through the car mic and the transcript + // lands on the search surface (commands parse there); resolved SYSTEM opens that surface + // directly - its inline host mic IS the system recognizer. Hidden only when nothing can + // serve voice at all (mode NONE), same as the phone. + if (voice.available()) { + strip.addAction( + voice.micAction( + this, ::invalidate, + onTranscript = { q -> + app.vela.car.VoiceCommandRouter.handle(carContext, deps, screenManager, q, alongRoute = false) + }, + ), + ) + } + + return PlaceListNavigationTemplate.Builder() + .setItemList(list.build()) + .setTitle(carContext.getString(app.vela.R.string.app_name)) + .setHeaderAction(Action.APP_ICON) + .setActionStrip(strip.build()) + .build() + } + + private fun destRow(name: String, dest: LatLng): Row { + val b = Row.Builder() + .setTitle(name) + .setBrowsable(true) + .setOnClickListener { screenManager.push(RoutePreviewCarScreen(carContext, deps, name, dest)) } + // Live drive time as the subtitle once fetched (Home/Work only - see fetchEtasOnce). + etaSecs[rowKey(dest)]?.let { secs -> + val t = SpannableString(" ") + t.setSpan(DurationSpan.create(secs), 0, 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + b.addText(CarText.create(t)) + } + return b.build() + } + + private fun rowKey(loc: LatLng) = "%.5f,%.5f".format(loc.lat, loc.lng) + + + /** Fetch live ETAs for Home + Work (the two rows a commuter reads daily). Once per screen; + * best-effort - a failed fetch just leaves the row plain. */ + private fun fetchEtasOnce() { + if (etaFetched) return + etaFetched = true + val targets = listOfNotNull( + deps.shortcuts.get(ShortcutKind.HOME)?.location, + deps.shortcuts.get(ShortcutKind.WORK)?.location, + ) + if (targets.isEmpty()) return + lifecycleScope.launch { + val from = deps.locationProvider.lastKnown() ?: return@launch + var got = false + targets.forEach { dest -> + val r = runCatching { + deps.mapDataSource.directions(from, dest, app.vela.core.model.TravelMode.DRIVE).firstOrNull() + }.getOrNull() + if (r != null) { + etaSecs[rowKey(dest)] = (r.durationInTrafficSeconds ?: r.durationSeconds).toLong() + got = true + } + } + if (got) invalidate() + } + } + + private fun icon(res: Int): CarIcon = + CarIcon.Builder(IconCompat.createWithResource(carContext, res)).build() + + private companion object { + // PlaceListNavigationTemplate hard-caps its list at 6 rows (exceeding it throws at build). + const val MAX_ROWS = 6 + } +} diff --git a/app/src/main/java/app/vela/car/screen/PlaceDetailsCarScreen.kt b/app/src/main/java/app/vela/car/screen/PlaceDetailsCarScreen.kt new file mode 100644 index 00000000..58f3e227 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/PlaceDetailsCarScreen.kt @@ -0,0 +1,54 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import app.vela.R +import app.vela.core.model.Place + +/** Place details on the car (a PaneTemplate the host content-gates while driving, full when + * parked): rating, address, category, phone, open status - whatever the search result carried. + * "Go" starts the normal route preview. */ +class PlaceDetailsCarScreen( + carContext: CarContext, + private val deps: CarDeps, + private val place: Place, +) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val pane = Pane.Builder() + place.rating?.let { r -> + val stars = "\u2605 %.1f".format(r) + (place.reviewCount?.let { " ($it)" } ?: "") + pane.addRow(Row.Builder().setTitle(stars).build()) + } + place.address?.takeIf { it.isNotBlank() }?.let { + pane.addRow(Row.Builder().setTitle(it).build()) + } + place.category?.takeIf { it.isNotBlank() }?.let { + pane.addRow(Row.Builder().setTitle(it).build()) + } + place.phone?.takeIf { it.isNotBlank() }?.let { + pane.addRow(Row.Builder().setTitle(it).build()) + } + if (place.rating == null && place.address.isNullOrBlank() && place.category.isNullOrBlank() && place.phone.isNullOrBlank()) { + pane.addRow(Row.Builder().setTitle(place.name).build()) + } + pane.addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.car_go)) + .setFlags(Action.FLAG_PRIMARY) + .setOnClickListener { + screenManager.push(RoutePreviewCarScreen(carContext, deps, place.name, place.location)) + } + .build(), + ) + return PaneTemplate.Builder(pane.build()) + .setTitle(place.name) + .setHeaderAction(Action.BACK) + .build() + } +} diff --git a/app/src/main/java/app/vela/car/screen/RoutePreviewCarScreen.kt b/app/src/main/java/app/vela/car/screen/RoutePreviewCarScreen.kt new file mode 100644 index 00000000..64a0f0ea --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/RoutePreviewCarScreen.kt @@ -0,0 +1,184 @@ +package app.vela.car.screen + +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.Screen +import android.text.SpannableString +import android.text.Spanned +import androidx.car.app.model.Action +import androidx.car.app.model.CarText +import androidx.car.app.model.Distance +import androidx.car.app.model.DistanceSpan +import androidx.car.app.model.DurationSpan +import androidx.car.app.model.ItemList +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import app.vela.core.model.LatLng +import app.vela.core.model.Route +import app.vela.core.model.TravelMode +import app.vela.service.NavigationService +import kotlinx.coroutines.launch + +/** Route preview for a chosen destination: fetches routes via [app.vela.core.data.MapDataSource], + * lists them with live ETAs on a map showing the route framed, and on "Go" names the picked route + * and starts [NavSession]. */ +class RoutePreviewCarScreen( + carContext: CarContext, + private val deps: CarDeps, + private val destName: String, + private val dest: LatLng, + private val place: app.vela.core.model.Place? = null, // search results carry the full place -> Details action +) : Screen(carContext), DefaultLifecycleObserver { + + private var routes: List = emptyList() + private var selected = 0 + private var loading = true + private var fetched = false + private var origin: LatLng? = null + + init { lifecycle.addObserver(this) } + + override fun onStop(owner: LifecycleOwner) { + // A non-map screen (search, categories, stops, details) is covering us - stop posting + // frames under the host's overlay (they tear through it). onStart's start() resumes. + deps.mapRenderer(carContext).pause() + } + + override fun onStart(owner: LifecycleOwner) { + // Shared renderer, preview mode (frame the selected route). Never clear/stop on transitions. + val renderer = deps.mapRenderer(carContext) + carContext.getCarService(AppManager::class.java).setSurfaceCallback(renderer) + renderer.start() + renderer.showPreview(routes.getOrNull(selected)) + } + + override fun onGetTemplate(): Template { + if (!fetched) { fetched = true; fetchRoutes() } + + val builder = RoutePreviewNavigationTemplate.Builder().setHeaderAction(Action.BACK) + builder.setTitle(destName) + place?.let { p -> + builder.setActionStrip( + androidx.car.app.model.ActionStrip.Builder().addAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_details)) + .setOnClickListener { screenManager.push(PlaceDetailsCarScreen(carContext, deps, p)) } + .build(), + ).build(), + ) + } + + if (loading) { + builder.setLoading(true) + return builder.build() + } + if (routes.isEmpty()) { + // No location / no route found. RoutePreviewNavigationTemplate REQUIRES a navigate action + // whenever its list is set (non-loading) — a "no items" list with none throws + // "navigation action cannot be null". So show a MessageTemplate instead of the preview. + return MessageTemplate.Builder(carContext.getString(app.vela.R.string.car_no_route)) + .setTitle(destName) + .setHeaderAction(Action.BACK) + .build() + } + + // RoutePreviewNavigationTemplate caps its route list at 3 — more rows THROW at build. + val shown = routes.take(3) + val list = ItemList.Builder() + shown.forEach { r -> list.addItem(routeRow(r)) } + list.setOnSelectedListener { idx -> selected = idx; deps.mapRenderer(carContext).showPreview(shown.getOrNull(idx)) } + list.setSelectedIndex(selected.coerceIn(0, shown.lastIndex)) + builder.setItemList(list.build()) + builder.setNavigateAction( + Action.Builder() + .setTitle(carContext.getString(app.vela.R.string.car_go)) + .setOnClickListener { startNav() } + .build(), + ) + return builder.build() + } + + private fun routeRow(r: Route): Row { + val secs = (r.durationInTrafficSeconds ?: r.durationSeconds).toLong() + val title = r.summary?.takeIf { it.isNotBlank() } ?: destName + // The RoutePreviewNavigationTemplate REQUIRES every route row to carry a DurationSpan or + // DistanceSpan on its title/texts (else "All rows must have either a distance or duration + // span attached" — a hard crash). Attach both: a DurationSpan and a DistanceSpan, each + // replacing a placeholder char, so the host renders the ETA + distance in its own format. + val text = SpannableString("  ·  ") + text.setSpan(DurationSpan.create(secs), 0, 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + text.setSpan( + DistanceSpan.create(Distance.create(distanceValue(r.distanceMeters), distanceUnit())), + text.length - 1, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE, + ) + return Row.Builder() + .setTitle(CarText.create(title)) + .addText(text) + .build() + } + + private fun distanceUnit(): Int = + if (app.vela.ui.Units.imperial.value) Distance.UNIT_MILES else Distance.UNIT_KILOMETERS + + private fun distanceValue(meters: Double): Double = + if (app.vela.ui.Units.imperial.value) meters / 1609.344 else meters / 1000.0 + + private fun fetchRoutes() { + // Don't invalidate() here — this runs during onGetTemplate (which already returns the loading + // template); invalidate mid-build is a no-op the host discourages. Only invalidate on completion. + loading = true + lifecycleScope.launch { + // The GPS fix may still be locking when this screen opens — poll up to ~15 s for a location + // instead of giving up immediately (which left the screen stuck on "loading" forever). + var from = deps.locationProvider.lastKnown() + var tries = 0 + while (from == null && tries < 30) { + kotlinx.coroutines.delay(500) + from = deps.locationProvider.lastKnown() + tries++ + } + origin = from + routes = if (from == null) emptyList() + else runCatching { deps.mapDataSource.directions(from, dest, TravelMode.DRIVE) }.getOrDefault(emptyList()) + loading = false + invalidate() + deps.mapRenderer(carContext).showPreview(routes.getOrNull(selected)) // frame the route once fetched + } + } + + private fun startNav() { + val from = origin ?: deps.locationProvider.lastKnown() ?: return + val picked = routes.getOrNull(selected) ?: routes.firstOrNull() ?: return + lifecycleScope.launch { + val named = if (picked.provisional) { + runCatching { deps.mapDataSource.nameRoute(picked, from, dest, TravelMode.DRIVE) }.getOrDefault(picked) + } else picked + // Speak through the user's chosen TTS engine, not the system default. Same + // stale-neural mapping as the phone's init path (a removed vela.* voice falls + // back to null = system TTS; the in-process neural synth is phone-side only). + val savedRaw = carContext.applicationContext + .getSharedPreferences("vela_settings", android.content.Context.MODE_PRIVATE) + .getString("voice_engine", null) + val engine = if (savedRaw == null || savedRaw.startsWith("vela.")) null else savedRaw + deps.navSession.start(named, dest, destName, engine, emptyList(), TravelMode.DRIVE) + // Record the destination into recent places (same store the phone and the landing + // screen read) - car-started trips never joined history before (user-caught gap). + runCatching { + deps.recentPlaces.add( + app.vela.core.model.SavedPlace( + id = place?.id ?: "car:%.5f,%.5f".format(dest.lat, dest.lng), + name = destName, lat = dest.lat, lng = dest.lng, + ), + ) + } + runCatching { NavigationService.start(carContext.applicationContext) } + screenManager.push(ActiveNavCarScreen(carContext, deps)) + } + } + +} diff --git a/app/src/main/java/app/vela/car/screen/SearchCarScreen.kt b/app/src/main/java/app/vela/car/screen/SearchCarScreen.kt new file mode 100644 index 00000000..cbf85595 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/SearchCarScreen.kt @@ -0,0 +1,207 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.ItemList +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.model.SearchTemplate +import androidx.lifecycle.lifecycleScope +import app.vela.core.data.RouteCorridor +import app.vela.core.model.Place +import app.vela.core.nav.NavSession +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Destination search on the car: debounced [app.vela.core.data.MapDataSource.search] biased to the + * last known location; tapping a result previews a route to it. A mic action (hosts on Car API 5+, + * when the on-device model is installed) records from the CAR's mic and transcribes with the same + * Whisper pipeline as the phone - see [CarVoiceSearch]. + * + * [alongRoute] flips it into the in-drive "search along route" mode: results are fetched around + * the route's midpoint and filtered to the corridor ([RouteCorridor], same as the phone), and + * tapping one ADDS IT AS A STOP via [NavSession.addStop] (the drive replans through it) instead + * of previewing a new trip. */ +class SearchCarScreen( + carContext: CarContext, + private val deps: CarDeps, + initialQuery: String? = null, // a transcript from another screen's mic, searched on entry + private val alongRoute: Boolean = false, +) : Screen(carContext) { + + private var results: List = emptyList() + private var searching = false + private var searchJob: Job? = null + private var voiceQuery: String? = null // last transcript, echoed into the search field + private val voiceLocal by lazy { app.vela.car.CarVoiceSearch(carContext, deps.whisper) } + + init { + // Plain search only - NO command dispatch and NO pushes from init: constructor args are + // evaluated before the caller's own push, so anything pushed here gets buried under this + // screen (review round 2). Commands from mics are routed by VoiceCommandRouter instead. + initialQuery?.let { voiceQuery = it; runSearch(it) } + } + + override fun onGetTemplate(): Template { + val callback = object : SearchTemplate.SearchCallback { + override fun onSearchTextChanged(searchText: String) = runSearch(searchText) + override fun onSearchSubmitted(searchText: String) = handleSubmitted(searchText) + } + val builder = SearchTemplate.Builder(callback) + .setHeaderAction(Action.BACK) + .setShowKeyboardByDefault(false) // keyboard only on a bar tap (user request) + voiceQuery?.let { builder.setInitialSearchText(it) } + // The host's in-field mic is the SYSTEM recognizer and cannot be removed or rewired. For + // a SYSTEM-resolved user that is correct - one mic, theirs. For a LOCAL (Vela) pin it + // would be a silent reroute to Google, so LOCAL mode adds the Vela strip mic back: its + // transcript stays fully on-device (review round 2 - the pin holds on every surface). + if (app.vela.ui.VoiceSearch.resolvedMode(carContext) == app.vela.ui.VoiceSearch.Mode.LOCAL) { + builder.setActionStrip( + androidx.car.app.model.ActionStrip.Builder().addAction( + voiceLocal.micAction( + this, ::invalidate, + onTranscript = { q -> voiceQuery = q; handleSubmitted(q) }, + ), + ).build(), + ) + } + if (searching) { + builder.setLoading(true) + } else { + val list = ItemList.Builder() + if (results.isEmpty() && alongRoute) { + // Google Maps-style: in-drive search offers the quick categories UNDER the bar + // before you type (user request) - each goes straight to corridor results whose + // pick becomes a stop. Exactly 6 rows = the template's row cap. + listOf( + Triple(app.vela.R.string.cat_gas, "Gas", app.vela.R.drawable.ic_car_gas), + Triple(app.vela.R.string.cat_food, "Food", app.vela.R.drawable.ic_car_food), + Triple(app.vela.R.string.cat_coffee, "Coffee", app.vela.R.drawable.ic_car_coffee), + Triple(app.vela.R.string.cat_groceries, "Groceries", app.vela.R.drawable.ic_car_grocery), + Triple(app.vela.R.string.cat_parking, "Parking", app.vela.R.drawable.ic_car_parking), + Triple(app.vela.R.string.cat_charging, "EV charging", app.vela.R.drawable.ic_car_charging), + ).forEach { (labelRes, query, iconRes) -> + list.addItem( + Row.Builder() + .setTitle(carContext.getString(labelRes)) + .setImage( + androidx.car.app.model.CarIcon.Builder( + androidx.core.graphics.drawable.IconCompat.createWithResource(carContext, iconRes), + ).build(), + Row.IMAGE_TYPE_SMALL, + ) + .setOnClickListener { + screenManager.push( + CategoryResultsCarScreen(carContext, deps, carContext.getString(labelRes), query, true), + ) + } + .build(), + ) + } + } else if (results.isEmpty()) { + list.setNoItemsMessage(carContext.getString(app.vela.R.string.car_search_hint)) + } else { + results.take(6).forEach { p -> + list.addItem( + Row.Builder() + .setTitle(p.name) + .apply { p.address?.let { addText(it) } } + // NOT browsable — SearchTemplate rows are plain clickable results (browsable + // implies a drill-in sublist and isn't valid here). onClick pushes preview. + .setOnClickListener { onPick(p) } + .build(), + ) + } + } + builder.setItemList(list.build()) + } + return builder.build() + } + + /** Submitted text OR a mic transcript: voice commands act ("navigate home" routes, "mute" + * mutes) and everything else searches. */ + private fun handleSubmitted(searchText: String) { + // Car searches join the SAME history the phone records (they never did - a port gap the + // user caught): submitted text and mic transcripts only, never per-keystroke suggestions. + if (searchText.isNotBlank()) deps.recentSearches.add(searchText) + when (val c = app.vela.core.voice.CarCommands.parse(searchText)) { + app.vela.core.voice.CarCommands.Command.Mute -> deps.voiceGuide.muted = true + app.vela.core.voice.CarCommands.Command.Unmute -> deps.voiceGuide.muted = false + app.vela.core.voice.CarCommands.Command.GoHome -> shortcutOrSearch(app.vela.core.model.ShortcutKind.HOME, searchText) + app.vela.core.voice.CarCommands.Command.GoWork -> shortcutOrSearch(app.vela.core.model.ShortcutKind.WORK, searchText) + app.vela.core.voice.CarCommands.Command.FindMyCar -> { + val spot = deps.parkingStore.current() + if (spot != null) { + screenManager.push( + RoutePreviewCarScreen( + carContext, deps, + carContext.getString(app.vela.R.string.map_parking_find), + app.vela.core.model.LatLng(spot.lat, spot.lng), + ), + ) + } else { + androidx.car.app.CarToast.makeText(carContext, app.vela.R.string.map_parking_no_fix, androidx.car.app.CarToast.LENGTH_SHORT).show() + } + } + app.vela.core.voice.CarCommands.Command.EndNav -> { + val nav = deps.navSession.state.value + if (nav.navigating) { + deps.navSession.stop() + runCatching { app.vela.service.NavigationService.stop(carContext.applicationContext) } + screenManager.popToRoot() + } else { + androidx.car.app.CarToast.makeText(carContext, app.vela.R.string.map_parking_no_fix, androidx.car.app.CarToast.LENGTH_SHORT).show() + } + } + is app.vela.core.voice.CarCommands.Command.Search -> runSearch(c.query) + } + } + + private fun shortcutOrSearch(kind: app.vela.core.model.ShortcutKind, fallback: String) { + val sc = deps.shortcuts.get(kind) + if (sc != null) screenManager.push(RoutePreviewCarScreen(carContext, deps, sc.name, sc.location)) + else runSearch(fallback) + } + + private fun runSearch(text: String) { + searchJob?.cancel() + if (text.isBlank()) { + results = emptyList(); searching = false; invalidate(); return + } + searching = true + invalidate() + searchJob = lifecycleScope.launch { + delay(300) // debounce + val route = if (alongRoute) deps.navSession.state.value.route?.polyline else null + val found = runCatching { + if (route != null && route.size >= 2) { + // Same shape as the phone's searchAlongRoute: bias the fetch to the route's + // midpoint, then keep only places within the corridor. + RouteCorridor.alongRoute(deps.mapDataSource.search(text, route[route.size / 2]).places, route) + } else { + deps.mapDataSource.search(text, deps.locationProvider.lastKnown()).places + } + }.getOrDefault(emptyList()) + results = found + searching = false + invalidate() + } + } + + private fun onPick(p: Place) { + val nav = deps.navSession.state.value + if (alongRoute && nav.navigating && !nav.arrived) { + val loc = deps.locationProvider.lastKnown() + if (loc != null) { + deps.navSession.addStop(NavSession.NavStop(p.location, p.name), loc) + CarToast.makeText(carContext, app.vela.R.string.car_stop_added, CarToast.LENGTH_SHORT).show() + screenManager.pop() // back to guidance; the replan announces itself by voice + return + } + } + screenManager.push(RoutePreviewCarScreen(carContext, deps, p.name, p.location, p)) + } +} diff --git a/app/src/main/java/app/vela/car/screen/StopsCarScreen.kt b/app/src/main/java/app/vela/car/screen/StopsCarScreen.kt new file mode 100644 index 00000000..c475dbc3 --- /dev/null +++ b/app/src/main/java/app/vela/car/screen/StopsCarScreen.kt @@ -0,0 +1,48 @@ +package app.vela.car.screen + +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import app.vela.R + +/** The in-drive stop manager: the remaining stops in travel order; tapping one REMOVES it and the + * drive replans through the rest ([app.vela.core.nav.NavSession.removeStop]). One-tap removal, no + * confirm - it is a glanceable driving surface, and an accidental removal is one search away from + * restored. The phone's stops editor has no car counterpart until this. */ +class StopsCarScreen(carContext: CarContext, private val deps: CarDeps) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val stops = deps.navSession.remainingStops() + val list = ItemList.Builder() + if (stops.isEmpty()) { + list.setNoItemsMessage(carContext.getString(R.string.car_no_stops)) + } else { + stops.forEach { stop -> + list.addItem( + Row.Builder() + .setTitle(stop.label) + .addText(carContext.getString(R.string.car_stop_remove_hint)) + .setOnClickListener { + val loc = deps.locationProvider.lastKnown() + if (loc != null) { + deps.navSession.removeStop(stop, loc) + CarToast.makeText(carContext, R.string.car_stop_removed, CarToast.LENGTH_SHORT).show() + invalidate() + } + } + .build(), + ) + } + } + return ListTemplate.Builder() + .setTitle(carContext.getString(R.string.car_stops)) + .setHeaderAction(Action.BACK) + .setSingleList(list.build()) + .build() + } +} diff --git a/app/src/main/java/app/vela/ui/DriveAlerts.kt b/app/src/main/java/app/vela/ui/DriveAlerts.kt new file mode 100644 index 00000000..acad7ab3 --- /dev/null +++ b/app/src/main/java/app/vela/ui/DriveAlerts.kt @@ -0,0 +1,35 @@ +package app.vela.ui + +import android.content.Context +import androidx.compose.runtime.mutableStateOf + +/** + * Driving-alert toggles (Settings -> Navigation -> Driving alerts), both OFF by default and both + * Android Auto-side today: [cameras] speaks a heads-up approaching a mapped license-plate camera + * (the bundled DeFlock dataset - the same one the map layer and route re-rank read); [speeding] + * turns the car speed badge red when driving over the posted limit. Process-wide reactive holder, + * same shape as [Units]/[VoiceSearch]; `init()`-ed in [app.vela.VelaApp]. + */ +object DriveAlerts { + val cameras = mutableStateOf(false) + val speeding = mutableStateOf(false) + + fun init(context: Context) { + cameras.value = prefs(context).getBoolean(KEY_CAMERAS, false) + speeding.value = prefs(context).getBoolean(KEY_SPEEDING, false) + } + + fun setCameras(context: Context, value: Boolean) { + cameras.value = value + prefs(context).edit().putBoolean(KEY_CAMERAS, value).apply() + } + + fun setSpeeding(context: Context, value: Boolean) { + speeding.value = value + prefs(context).edit().putBoolean(KEY_SPEEDING, value).apply() + } + + private fun prefs(c: Context) = c.getSharedPreferences("vela_settings", Context.MODE_PRIVATE) + private const val KEY_CAMERAS = "drive_alert_cameras" + private const val KEY_SPEEDING = "drive_alert_speeding" +} diff --git a/app/src/main/java/app/vela/ui/map/MapFonts.kt b/app/src/main/java/app/vela/ui/map/MapFonts.kt new file mode 100644 index 00000000..92962247 --- /dev/null +++ b/app/src/main/java/app/vela/ui/map/MapFonts.kt @@ -0,0 +1,122 @@ +package app.vela.ui.map + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import app.vela.BuildConfig +import app.vela.core.data.tiles.MapStyle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Roboto on the basemap, keyless. OpenFreeMap's glyph server only carries Noto + * (every Roboto stack 404s, probed 2026-07-11), so Vela hosts its own glyph set on + * the repo's GitHub Pages: Roboto composited over OpenFreeMap's Noto per glyph - + * Roboto wins Latin/Cyrillic/Greek, Noto keeps every other script, and the folder + * names stay "Noto Sans Regular/Bold/Italic" so the ONLY style change is the + * `glyphs` URL. (Recipe: scripts/build-map-fonts.sh -> the `map-fonts` release -> + * fdroid-repo.yml unpacks it into the Pages site.) + * + * At launch this fetches the LIVE Liberty style JSON - so tile paths keep + * auto-following OpenFreeMap's snapshot rotation, the exact property the old + * bundled-asset attempt lost - patches `glyphs`, and caches the result; the map + * loads the cache as `file://` on later launches (no restyle flash). Guards, in + * order of what actually goes wrong: + * - the font host is PROBED (range 0-255) before the patched style is used or + * kept; an unreachable host EVICTS the cache, because a style whose glyph URLs + * fail renders a map with no labels at all - falling back to the plain Liberty + * URL (Noto) is invisible by comparison; + * - a style fetch failure keeps the last-good cache, but only for + * [STALE_EVICT_MS]: a cached style pins a tile snapshot, and OpenFreeMap + * eventually retires old snapshots (the blank-basemap failure the bundled + * asset hit) - past the window the plain URL wins again; + * - only [MapStyle.LIBERTY] is ever patched; MapTiler and offline REGION + * DEFINITIONS (OfflineMaps) keep the plain URL on purpose - a definition + * outlives any file path, and its download caching Noto glyph URLs is the + * offline fallback. Offline label coverage for the patched style rides the + * ambient cache (browsing an area warms the ranges it uses). + */ +object MapFonts { + private val patched = mutableStateOf(null) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private const val STALE_EVICT_MS = 7L * 24 * 3600 * 1000 + private const val CACHE_NAME = "liberty-roboto.json" + + /** The style the map should load: the patched Liberty when ready, else [base] untouched. */ + fun effective(base: String): String = + if (base == MapStyle.LIBERTY.uri) patched.value ?: base else base + + fun init(context: Context) { + val f = cacheFile(context) + if (f.isFile && f.length() > 0) patched.value = "file://${f.absolutePath}" + scope.launch { runCatching { refresh(context) } } + } + + private fun cacheFile(context: Context) = + File(File(context.filesDir, "style").apply { mkdirs() }, CACHE_NAME) + + private fun evict(f: File) { + patched.value = null + f.delete() + } + + private fun refresh(context: Context) { + val f = cacheFile(context) + val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + val fontsBase = BuildConfig.MAP_FONTS_URL.trimEnd('/') + + // A host that can't serve glyphs must never be in the style the map renders. + val probeUrl = "$fontsBase/Noto%20Sans%20Regular/0-255.pbf" + val fontsOk = runCatching { + http.newCall(Request.Builder().url(probeUrl).build()).execute().use { r -> + // Manual head read: InputStream.readNBytes is API 33+, minSdk is 26. + val head = ByteArray(16) + var n = 0 + r.body?.byteStream()?.let { s -> + while (n < head.size) { + val read = s.read(head, n, head.size - n) + if (read < 0) break + n += read + } + } + r.isSuccessful && n == head.size && head[0] == 0x0a.toByte() + } + }.getOrDefault(false) + if (!fontsOk) { + evict(f) + return + } + + val live = runCatching { + http.newCall(Request.Builder().url(MapStyle.LIBERTY.uri).build()).execute().use { r -> + if (r.isSuccessful) r.body?.string() else null + } + }.getOrNull() + if (live == null) { + // Keep the last-good patch while it's fresh; a week offline and the plain + // live URL (which can't pin a dead tile snapshot) takes over. + if (f.isFile && System.currentTimeMillis() - f.lastModified() > STALE_EVICT_MS) evict(f) + return + } + + val patchedJson = runCatching { + JSONObject(live).put("glyphs", "$fontsBase/{fontstack}/{range}.pbf").toString() + }.getOrNull() ?: return + if (f.isFile && f.readText() == patchedJson) { + f.setLastModified(System.currentTimeMillis()) + return + } + val tmp = File(f.parentFile, "$CACHE_NAME.tmp") + tmp.writeText(patchedJson) + if (tmp.renameTo(f)) patched.value = "file://${f.absolutePath}" + } +} diff --git a/app/src/main/java/app/vela/ui/map/MapScreen.kt b/app/src/main/java/app/vela/ui/map/MapScreen.kt index e65cbae5..ff09d3e9 100644 --- a/app/src/main/java/app/vela/ui/map/MapScreen.kt +++ b/app/src/main/java/app/vela/ui/map/MapScreen.kt @@ -124,6 +124,8 @@ import app.vela.core.config.Notice import app.vela.core.model.distanceTo import app.vela.core.model.ManeuverType import app.vela.core.model.Place +import app.vela.core.data.RecentPlace +import app.vela.core.data.RecentQuery import app.vela.core.model.SavedPlace import app.vela.core.model.ShortcutKind import app.vela.ui.RatingStars @@ -233,7 +235,10 @@ fun MapScreen( val variant = if (darkTheme) "streets-v2-dark" else "streets-v2" "https://api.maptiler.com/maps/$variant/style.json?key=${BuildConfig.MAPTILER_KEY}" } else { - state.styleUri + // Liberty is swapped for the cached Roboto-glyph patch when MapFonts has it + // ready (reactive - flips once the first refresh lands). Offline REGION + // DEFINITIONS deliberately keep state.styleUri (see MapFonts). Ported upstream. + app.vela.ui.map.MapFonts.effective(state.styleUri) } val context = LocalContext.current @@ -2810,8 +2815,8 @@ private fun SearchEntryContent( suggestions: List, query: String, saved: List, - recents: List, - recentPlaces: List, + recents: List, + recentPlaces: List, home: SavedPlace?, work: SavedPlace?, assigning: ShortcutKind?, @@ -2837,9 +2842,10 @@ private fun SearchEntryContent( // the network suggestions and are deduped from them by name, each a plain SuggestionRow = one // D-pad focus stop. No network, so they appear before the Google fetch even returns. val q = query.trim() - val localQueries = if (q.length >= 2) recents.filter { it.contains(q, ignoreCase = true) && !it.equals(q, ignoreCase = true) }.take(3) else emptyList() + // Recents are typed entries since the upstream 381cfd49 timeline merge - match on the text. + val localQueries = if (q.length >= 2) recents.map { it.query }.filter { it.contains(q, ignoreCase = true) && !it.equals(q, ignoreCase = true) }.take(3) else emptyList() val localPlaces = if (q.length >= 2) { - (recentPlaces + saved).distinctBy { it.id } + (recentPlaces.map { it.place } + saved).distinctBy { it.id } .filter { it.name.contains(q, ignoreCase = true) } .filterNot { lp -> suggestions.any { it.name.equals(lp.name, ignoreCase = true) } } // network wins the dupe .take(3) @@ -2926,34 +2932,40 @@ private fun SearchEntryContent( Divider() } } - // Recently-opened places (pin icon) - one tap back to a place you just viewed. - if (recentPlaces.isNotEmpty()) { + // ONE chronological "Recent" list — Google mixes recently-viewed places and recent + // searches by time rather than bucketing them; the icon tells them apart (pin for a + // place you opened, clock for a query you typed). (Ported upstream 381cfd49.) + if (recentPlaces.isNotEmpty() || recents.isNotEmpty()) { SectionLabel(stringResource(R.string.mapscreen_section_recent)) - recentPlaces.forEach { rp -> - SuggestionRow( - icon = Icons.Default.Place, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - label = rp.name, - onClick = { onPickRecentPlace(rp) }, - ) - Divider() + val merged: List = (recentPlaces + recents).sortedByDescending { entry -> + when (entry) { + is RecentPlace -> entry.at + is RecentQuery -> entry.at + else -> 0L + } } - } - if (recents.isNotEmpty()) { - SectionLabel(stringResource(R.string.mapscreen_section_recent_searches)) - recents.forEach { q -> - SuggestionRow( - icon = Icons.Default.History, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - label = q, - onClick = { onPickRecent(q) }, - ) + merged.forEach { entry -> + when (entry) { + is RecentPlace -> SuggestionRow( + icon = Icons.Default.Place, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + label = entry.place.name, + onClick = { onPickRecentPlace(entry.place) }, + ) + is RecentQuery -> SuggestionRow( + icon = Icons.Default.History, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + label = entry.query, + onClick = { onPickRecent(entry.query) }, + ) + } Divider() } - // The start padding moves to the ring box so the ring hugs the button, not the inset. + // The start padding moves to the ring box so the ring hugs the button, not the inset + // (fork D-pad ring kept through the upstream timeline merge). DpadRingBox(androidx.compose.material3.ButtonDefaults.textShape, Modifier.padding(start = 8.dp)) { TextButton(onClick = onClearRecents) { - Text(stringResource(R.string.mapscreen_clear_recent_searches)) + Text(stringResource(R.string.mapscreen_clear_recents)) } } } @@ -3046,14 +3058,27 @@ private fun SavedRow( ) { Icon(Icons.Default.Star, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(16.dp)) - Text(place.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + // Explicit colours: the search page is a background()-Box, not a Surface, so + // LocalContentColor is unset and a colourless Text/Icon renders BLACK in dark + // mode (same trap ShortcutRow documents). Match the SuggestionRow siblings. + Text( + place.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) var menu by remember { mutableStateOf(false) } Box { IconButton( modifier = Modifier.dpadHighlight(androidx.compose.foundation.shape.CircleShape), onClick = { menu = true }, ) { - Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.mapscreen_saved_place_options)) + // onSurfaceVariant tint: readable in dark mode (upstream 381cfd49); the fork ring stays. + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.mapscreen_saved_place_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } VelaMenu(expanded = menu, onDismissRequest = { menu = false }) { item(stringResource(R.string.mapscreen_set_as_home)) { menu = false; onPinAs(place, ShortcutKind.HOME) } diff --git a/app/src/main/java/app/vela/ui/map/MapViewModel.kt b/app/src/main/java/app/vela/ui/map/MapViewModel.kt index affecdf5..34de8839 100644 --- a/app/src/main/java/app/vela/ui/map/MapViewModel.kt +++ b/app/src/main/java/app/vela/ui/map/MapViewModel.kt @@ -225,8 +225,8 @@ data class MapUiState( val selectedEngine: VoiceEngine? = null, val searching: Boolean = false, val resultsCollapsed: Boolean = false, - val recents: List = emptyList(), - val recentPlaces: List = emptyList(), + val recents: List = emptyList(), + val recentPlaces: List = emptyList(), val saved: List = emptyList(), val home: SavedPlace? = null, val work: SavedPlace? = null, diff --git a/app/src/main/java/app/vela/ui/map/VelaMapView.kt b/app/src/main/java/app/vela/ui/map/VelaMapView.kt index e93dabe0..d1d0aab9 100644 --- a/app/src/main/java/app/vela/ui/map/VelaMapView.kt +++ b/app/src/main/java/app/vela/ui/map/VelaMapView.kt @@ -1536,6 +1536,14 @@ fun VelaMapView( val json = context.assets.open(styleUri.removePrefix("asset://")) .bufferedReader().use { it.readText() } Style.Builder().fromJson(json) + } else if (styleUri.startsWith("file://")) { + // MapFonts' patched Liberty (live style re-pointed at the Roboto + // glyph host). Read + fromJson rather than trusting native file:// + // handling; a vanished/empty file falls back to the plain URL. + val f = java.io.File(styleUri.removePrefix("file://")) + val json = runCatching { f.readText() }.getOrNull() + if (json.isNullOrBlank()) Style.Builder().fromUri(app.vela.core.data.tiles.MapStyle.LIBERTY.uri) + else Style.Builder().fromJson(json) } else { Style.Builder().fromUri(styleUri) } @@ -2590,6 +2598,9 @@ internal fun applyLight(style: Style) { } /** Google-Maps-dark-ish palette applied over the OpenMapTiles layers. */ +// NB the Android Auto night map mirrors this exact palette as a style-JSON transform +// (app/car/CarNightStyle.darken - the snapshotter has no live Style to mutate). A colour or +// layer-list change here must be mirrored there, or the car's night map drifts from the phone's. internal fun applyDark(style: Style) { style.getLayer("background")?.setProperties(PropertyFactory.backgroundColor("#242f3e")) style.getLayer("water")?.setProperties(PropertyFactory.fillColor("#17263c")) diff --git a/app/src/main/java/app/vela/ui/settings/sections/AboutSettings.kt b/app/src/main/java/app/vela/ui/settings/sections/AboutSettings.kt index 29d18bef..ec60c802 100644 --- a/app/src/main/java/app/vela/ui/settings/sections/AboutSettings.kt +++ b/app/src/main/java/app/vela/ui/settings/sections/AboutSettings.kt @@ -135,6 +135,11 @@ internal fun AboutSettingsScreen(vm: MapViewModel, onBack: () -> Unit) { ) { Text(stringResource(R.string.settings_update_check_now)) } } updateStatus?.let { androidx.compose.foundation.layout.Box(Modifier.padding(horizontal = 4.dp)) { Hint(it) } } + // ODbL attribution home: the car map no longer stamps the credit onto every frame (user + // request), so About is where the OpenStreetMap notice permanently lives. + androidx.compose.foundation.layout.Box(Modifier.padding(horizontal = 4.dp, vertical = 6.dp)) { + Hint(stringResource(R.string.settings_map_attribution)) + } } // Breathing room under the last control so the button doesn't sit right on the // gesture bar at the end of the scroll. diff --git a/app/src/main/java/app/vela/ui/settings/sections/NavigationSettings.kt b/app/src/main/java/app/vela/ui/settings/sections/NavigationSettings.kt index 694ae1cc..f891539f 100644 --- a/app/src/main/java/app/vela/ui/settings/sections/NavigationSettings.kt +++ b/app/src/main/java/app/vela/ui/settings/sections/NavigationSettings.kt @@ -107,6 +107,27 @@ internal fun NavigationSettingsScreen(vm: MapViewModel, onBack: () -> Unit) { ) } + // Driving alerts (Android Auto side today): a spoken heads-up approaching a mapped + // license-plate camera, and a red speed badge over the posted limit. Reactive holder + // (DriveAlerts) so the car session reads flips live, same shape as VoiceSearch. + SettingsGroup(stringResource(R.string.settings_drive_alerts)) { + val cameraAlerts by app.vela.ui.DriveAlerts.cameras + ToggleRow( + label = stringResource(R.string.settings_alert_cameras), + checked = cameraAlerts, + onCheckedChange = { app.vela.ui.DriveAlerts.setCameras(context, it) }, + hint = stringResource(R.string.settings_alert_cameras_hint), + ) + val speedWarn by app.vela.ui.DriveAlerts.speeding + GroupDivider() + ToggleRow( + label = stringResource(R.string.settings_alert_speeding), + checked = speedWarn, + onCheckedChange = { app.vela.ui.DriveAlerts.setSpeeding(context, it) }, + hint = stringResource(R.string.settings_alert_speeding_hint), + ) + } + SettingsGroup { androidx.compose.foundation.layout.Column(Modifier.padding(horizontal = 4.dp)) { Text(stringResource(R.string.settings_vibrate_on_turns), style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, modifier = Modifier.padding(top = 2.dp)) diff --git a/app/src/main/java/app/vela/voice/WhisperRecognizer.kt b/app/src/main/java/app/vela/voice/WhisperRecognizer.kt index 1ac5440f..ea23bd4f 100644 --- a/app/src/main/java/app/vela/voice/WhisperRecognizer.kt +++ b/app/src/main/java/app/vela/voice/WhisperRecognizer.kt @@ -223,7 +223,8 @@ class WhisperRecognizer @Inject constructor( focusRequest = null } - private companion object { + // Internal (not private) so CarVoiceSearch can size its byte buffer off VAD_WINDOW. + internal companion object { // Grep target for a tester's logcat: every listen() failure logs under this tag with its // VoiceResult.Reason, so "it doesn't work" arrives already diagnosed (issue #81). const val TAG = "VELAASR" @@ -238,6 +239,7 @@ class WhisperRecognizer @Inject constructor( const val SAMPLE_RATE = 16000 const val VAD_WINDOW = 512 // Silero v4/v5 window at 16 kHz const val MAX_SECONDS = 15 // hard cap on one utterance + const val EMPTY_READ_SLEEP_MS = 10L // zero-byte read backoff (car mic can be nonblocking) // Drop the loaded model after this quiet period (issue #83): long enough that a dictation // session never reaps between utterances, short enough that a session-long 267 MB hold // cannot happen. RAM-SCALED, not flat: a reload costs ~1 s of dead mic on the next tap, @@ -432,6 +434,16 @@ class WhisperRecognizer @Inject constructor( return r } + /** A 16 kHz mono PCM16 feed that [listen] drains. The phone mic is the default; the Android + * Auto search screen substitutes the car's mic (CarAudioRecord, which records at the same + * 16 kHz) through this, so the VAD + decode pipeline is identical on both. [read] returns the + * number of shorts read (<= size); [start] may throw (surfaces as [VoiceResult.Reason.RECORDING]). */ + interface PcmSource { + fun start() + fun read(buf: ShortArray, size: Int): Int + fun stop() + } + /** * Record from the mic and return what was said, or null if nothing usable was heard (or the * model/permission isn't there). [onLevel] gets a 0..1 loudness for the listening animation, @@ -450,6 +462,7 @@ class WhisperRecognizer @Inject constructor( onLevel: (Float) -> Unit, onListening: () -> Unit, cancelled: () -> Boolean, + source: PcmSource? = null, ): VoiceResult { // Acquire (and load) OFF the main thread: this can be a ~1 s native load, and callers reach // listen() from a UI coroutine. The lease is taken here rather than inside listenInner so @@ -469,7 +482,7 @@ class WhisperRecognizer @Inject constructor( Timber.tag(TAG).e("listen failed: MODEL (model absent or native load failed)") return VoiceResult.Failed(VoiceResult.Reason.MODEL, "model absent or native load failed") } - return listenInner(rec, onLevel, onListening, cancelled) + return listenInner(rec, onLevel, onListening, cancelled, source) } finally { if (leased) releaseLease() armIdleReap() // restart the quiet period from the END of this utterance @@ -481,6 +494,7 @@ class WhisperRecognizer @Inject constructor( onLevel: (Float) -> Unit, onListening: () -> Unit, cancelled: () -> Boolean, + source: PcmSource? = null, ): VoiceResult = withContext(Dispatchers.Default) { fun fail(reason: VoiceResult.Reason, detail: String? = null): VoiceResult.Failed { Timber.tag(TAG).e("listen failed: $reason${detail?.let { " ($it)" } ?: ""}") @@ -505,22 +519,32 @@ class WhisperRecognizer @Inject constructor( ) }.getOrNull() ?: return@withContext fail(VoiceResult.Reason.VAD, "silero VAD would not construct") - val minBuf = AudioRecord.getMinBufferSize( - SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - ) - val audio = runCatching { - AudioRecord( - MediaRecorder.AudioSource.VOICE_RECOGNITION, - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - maxOf(minBuf, SAMPLE_RATE * 2), + val audio = source ?: run { + val minBuf = AudioRecord.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - }.getOrNull() - if (audio == null || audio.state != AudioRecord.STATE_INITIALIZED) { - val detail = if (audio == null) "constructor threw" else "state=${audio.state} minBuf=$minBuf" - audio?.release(); vad.release() - return@withContext fail(VoiceResult.Reason.AUDIO_INIT, detail) + val mic = runCatching { + AudioRecord( + MediaRecorder.AudioSource.VOICE_RECOGNITION, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + maxOf(minBuf, SAMPLE_RATE * 2), + ) + }.getOrNull() + if (mic == null || mic.state != AudioRecord.STATE_INITIALIZED) { + val detail = if (mic == null) "constructor threw" else "state=${mic.state} minBuf=$minBuf" + mic?.release(); vad.release() + return@withContext fail(VoiceResult.Reason.AUDIO_INIT, detail) + } + object : PcmSource { + override fun start() = mic.startRecording() + override fun read(buf: ShortArray, size: Int): Int = mic.read(buf, 0, size) + override fun stop() { + runCatching { mic.stop() } + runCatching { mic.release() } + } + } } val buf = ShortArray(VAD_WINDOW) @@ -528,18 +552,41 @@ class WhisperRecognizer @Inject constructor( var total = 0 var sawSpeech = false var segment: FloatArray? = null + // VAD feed accumulator: Silero only accepts exact windows, and a pipe-backed car source + // routinely returns PARTIAL chunks - feeding only exactly-full reads starved the VAD + // (review round 2). Partials accumulate here and drain in whole windows. + val vadAcc = FloatArray(VAD_WINDOW) + var vadFill = 0 + // Wall-clock deadline: the sample-count cap never trips on a source that yields zero + // bytes forever (car mic opens, never delivers) - focus was held indefinitely. + val deadlineMs = android.os.SystemClock.elapsedRealtime() + (MAX_SECONDS + 5) * 1000L try { requestAudioFocus() // pause any playing music/podcast while we listen - audio.startRecording() + audio.start() onListening() - while (!cancelled() && segment == null && total < SAMPLE_RATE * MAX_SECONDS) { - val n = audio.read(buf, 0, VAD_WINDOW) - if (n <= 0) continue + while (!cancelled() && segment == null && total < SAMPLE_RATE * MAX_SECONDS && + android.os.SystemClock.elapsedRealtime() < deadlineMs + ) { + val n = audio.read(buf, VAD_WINDOW) + // n < 0 is TERMINAL (CarAudioRecord returns -1 forever once the host remote-closes + // the mic): continuing spun this loop at 100% CPU holding audio focus (review + // finding). n == 0 is just no-data-yet; breathe instead of hot-looping. + if (n < 0) { + runCatching { vad.release() } + return@withContext fail(VoiceResult.Reason.RECORDING, "source read returned $n") + } + if (n == 0) { Thread.sleep(EMPTY_READ_SLEEP_MS); continue } val f = FloatArray(n) { buf[it] / 32768f } onLevel(rms(f)) chunks.add(f) total += n - if (n == VAD_WINDOW) vad.acceptWaveform(f) + var off = 0 + while (off < n) { + val take = minOf(VAD_WINDOW - vadFill, n - off) + f.copyInto(vadAcc, vadFill, off, off + take) + vadFill += take; off += take + if (vadFill == VAD_WINDOW) { vad.acceptWaveform(vadAcc.copyOf()); vadFill = 0 } + } if (vad.isSpeechDetected()) sawSpeech = true // A finished speech segment (speech then a beat of silence) = the utterance. if (!vad.empty()) { @@ -555,7 +602,6 @@ class WhisperRecognizer @Inject constructor( // guarded so one failure can't skip the rest and leave playback paused forever. abandonAudioFocus() // let the music resume runCatching { audio.stop() } - runCatching { audio.release() } } // Prefer the VAD-trimmed segment (leading/trailing silence stripped -> cleaner transcript); diff --git a/app/src/main/res/drawable/ic_car_categories_tile.xml b/app/src/main/res/drawable/ic_car_categories_tile.xml new file mode 100644 index 00000000..a2cf54b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_categories_tile.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_charging.xml b/app/src/main/res/drawable/ic_car_charging.xml new file mode 100644 index 00000000..6cea04e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_charging.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_coffee.xml b/app/src/main/res/drawable/ic_car_coffee.xml new file mode 100644 index 00000000..b2804f84 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_coffee.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_food.xml b/app/src/main/res/drawable/ic_car_food.xml new file mode 100644 index 00000000..e749a364 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_food.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_gas.xml b/app/src/main/res/drawable/ic_car_gas.xml new file mode 100644 index 00000000..9eca74f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_gas.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_grocery.xml b/app/src/main/res/drawable/ic_car_grocery.xml new file mode 100644 index 00000000..c56d9796 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_grocery.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_car_mic2.xml b/app/src/main/res/drawable/ic_car_mic2.xml new file mode 100644 index 00000000..572643f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_mic2.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_car_parking.xml b/app/src/main/res/drawable/ic_car_parking.xml new file mode 100644 index 00000000..4d0a63de --- /dev/null +++ b/app/src/main/res/drawable/ic_car_parking.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3f21e7e4..fa779b16 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -311,8 +311,7 @@ Dauerhaft geschlossen Gespeichert Zuletzt verwendet - Letzte Suchen - Letzte Suchen löschen + Verlauf löschen Suchen Sie nach Orten, Adressen und Kategorien. Adresse für %1$s festlegen %1$s bearbeiten @@ -542,4 +541,30 @@ Ein Teil des Sprachmodells fehlt. Lade es unter Einstellungen \u2192 Suche erneut herunter. Dieses Telefon hat sein Mikrofon nicht für Vela geöffnet. Schließe andere Apps, die Audio aufnehmen, und versuche es erneut. Die Aufnahme wurde unerwartet beendet. Versuche es erneut. + + Suchen + Noch keine gespeicherten oder letzten Ziele + Tippen, um ein Ziel zu suchen + Los + Beenden + Stumm + Ton an + Schneller −%1$d Min + Keine Route, Standort prüfen + Nichts verstanden + Stopp hinzugefügt + Kategorien + Fahrwarnungen + Kamerawarnungen + Sprachhinweis nahe einer Kennzeichenkamera. Nur Android Auto. + Tempowarnung + Die Tempoanzeige im Auto wird über dem Limit rot. + Kamera voraus + Parken + E-Ladestationen + Stopps + Keine Stopps auf dieser Fahrt + Stopp entfernt + Zum Entfernen tippen + Details diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f5de8fdd..c19f3331 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -311,8 +311,7 @@ Cerrado permanentemente Guardados Recientes - Búsquedas recientes - Borrar búsquedas recientes + Borrar recientes Busca lugares, direcciones y categorías. Establecer dirección de %1$s Editar %1$s @@ -542,4 +541,30 @@ Falta parte del modelo de voz. Vuelve a descargarlo en Ajustes \u2192 Búsqueda. Este teléfono no abrió su micrófono para Vela. Cierra cualquier otra app que grabe audio e inténtalo de nuevo. La grabación se detuvo inesperadamente. Inténtalo de nuevo. + + Buscar + Aún no hay destinos guardados o recientes + Escribe para buscar un destino + Ir + Finalizar + Silenciar + Activar sonido + Más rápido −%1$d min + No hay ruta disponible: comprueba tu ubicación + No se entendió + Parada añadida + Categorías + Alertas de conducción + Alertas de cámaras + Aviso hablado cerca de una cámara de matrículas. Solo Android Auto. + Aviso de exceso de velocidad + El indicador de velocidad del coche se pone rojo sobre el límite. + Cámara adelante + Aparcamiento + Carga de VE + Paradas + Sin paradas en este viaje + Parada eliminada + Toca para eliminar + Detalles diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 152ad4b3..9bde8bcb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -311,8 +311,7 @@ Définitivement fermé Enregistrés Récents - Recherches récentes - Effacer les recherches récentes + Effacer les éléments récents Recherchez des lieux, adresses et catégories. Définir l\'adresse %1$s Modifier %1$s @@ -542,4 +541,30 @@ Une partie du modèle vocal est manquante. Téléchargez-le à nouveau dans Paramètres \u2192 Recherche. Ce téléphone n\'a pas ouvert son micro pour Vela. Fermez toute autre application qui enregistre du son et réessayez. L\'enregistrement s\'est arrêté de manière inattendue. Réessayez. + + Rechercher + Aucune destination enregistrée ou récente + Saisissez pour rechercher une destination + Aller + Terminer + Muet + Réactiver + Plus rapide −%1$d min + Aucun itinéraire, vérifiez votre position + Rien n\'a été entendu + Arrêt ajouté + Catégories + Alertes de conduite + Alertes caméras + Annonce vocale près d\'une caméra de plaques. Android Auto uniquement. + Alerte de vitesse + Le badge de vitesse devient rouge au-dessus de la limite. + Caméra devant + Parking + Recharge VE + Arrêts + Aucun arrêt sur ce trajet + Arrêt supprimé + Appuyez pour supprimer + Détails diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0dab89a6..595480b3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -311,8 +311,7 @@ Chiuso definitivamente Salvati Recenti - Ricerche recenti - Cancella ricerche recenti + Cancella recenti Cerca luoghi, indirizzi e categorie. Imposta indirizzo %1$s Modifica %1$s @@ -542,4 +541,30 @@ Manca una parte del modello vocale. Scaricalo di nuovo in Impostazioni \u2192 Ricerca. Questo telefono non ha aperto il microfono per Vela. Chiudi altre app che registrano audio e riprova. La registrazione si è interrotta inaspettatamente. Riprova. + + Cerca + Ancora nessuna destinazione salvata o recente + Digita per cercare una destinazione + Vai + Termina + Muto + Riattiva + Più veloce −%1$d min + Nessun percorso, controlla la posizione + Nessuna voce rilevata + Tappa aggiunta + Categorie + Avvisi di guida + Avvisi telecamere + Avviso vocale vicino a una telecamera targhe. Solo Android Auto. + Avviso velocità + Il badge velocità diventa rosso oltre il limite. + Telecamera in arrivo + Parcheggio + Ricarica EV + Tappe + Nessuna tappa in questo viaggio + Tappa rimossa + Tocca per rimuovere + Dettagli diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 9169ce5e..ae3f4186 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -504,8 +504,7 @@ מחק תנועה נוכחית 4.0★ - חיפושים אחרונים - ניקוי חיפושים אחרונים + נקה אחרונים לעזור לשפר את Vela? שניהם נשארים בטלפון שלך. שום דבר לא נשלח אלא אם תייצא אותו. ניתן לשנות כל אחד בכל עת בהגדרות. שיתוף אבחון @@ -545,4 +544,30 @@ חלק ממודל הקול חסר. הורד אותו שוב בהגדרות \u2192 חיפוש. הטלפון לא פתח את המיקרופון עבור Vela. סגור אפליקציות אחרות שמקליטות ונסה שוב. ההקלטה נעצרה באופן לא צפוי. נסה שוב. + + חיפוש + אין עדיין יעדים שמורים או אחרונים + הקלידו כדי לחפש יעד + צא + סיום + השתק + בטל השתקה + מהיר יותר −%1$d דק׳ + אין מסלול זמין — בדקו את המיקום + לא נקלט דיבור + העצירה נוספה + קטגוריות + התראות נהיגה + התראות מצלמות + הכרזה קולית ליד מצלמת לוחיות רישוי. רק ב-Android Auto. + אזהרת מהירות + תג המהירות ברכב הופך אדום מעל המגבלה. + מצלמה לפניך + חניה + טעינת רכב חשמלי + עצירות + אין עצירות בנסיעה זו + העצירה הוסרה + הקש להסרה + פרטים diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 78a08dbf..8b7bacac 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -524,8 +524,7 @@ 削除 現在の交通状況 4.0★ - 最近の検索 - 最近の検索を消去 + 履歴を消去 Velaの改善に協力しますか? どちらも端末内に保存されます。エクスポートしない限り送信されません。設定でいつでも変更できます。 診断情報を共有 @@ -562,4 +561,30 @@ 音声モデルの一部が不足しています。設定 \u2192 検索 から再ダウンロードしてください。 この端末でマイクを開けませんでした。録音中の他のアプリを閉じて再試行してください。 録音が予期せず停止しました。もう一度お試しください。 + + 検索 + 保存済み・最近の目的地はまだありません + 入力して目的地を検索 + 開始 + 終了 + ミュート + ミュート解除 + より速い −%1$d分 + ルートがありません — 位置情報を確認してください + 聞き取れませんでした + 経由地を追加しました + カテゴリ + 運転アラート + カメラ警告 + ナンバープレートカメラ付近で音声通知。Android Autoのみ。 + 速度超過警告 + 制限速度を超えると車の速度バッジが赤くなります。 + この先にカメラ + 駐車場 + EV充電 + 経由地 + 経由地はありません + 経由地を削除しました + タップで削除 + 詳細 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e32a6e8c..e4e0fb3c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -311,8 +311,7 @@ Permanent gesloten Opgeslagen Recent - Recente zoekopdrachten - Recente zoekopdrachten wissen + Recente items wissen Zoek naar plaatsen, adressen en categorieën. %1$s-adres instellen %1$s bewerken @@ -542,4 +541,30 @@ Een deel van het spraakmodel ontbreekt. Download het opnieuw via Instellingen \u2192 Zoeken. Deze telefoon opende de microfoon niet voor Vela. Sluit andere apps die audio opnemen en probeer opnieuw. De opname stopte onverwacht. Probeer opnieuw. + + Zoeken + Nog geen opgeslagen of recente bestemmingen + Typ om een bestemming te zoeken + Ga + Beëindigen + Dempen + Geluid aan + Sneller −%1$d min + Geen route, controleer je locatie + Niets verstaan + Stop toegevoegd + Categorieën + Rijwaarschuwingen + Camerawaarschuwingen + Gesproken melding bij een kentekencamera. Alleen Android Auto. + Snelheidswaarschuwing + De snelheidsbadge wordt rood boven de limiet. + Camera vooruit + Parkeren + EV-laden + Stops + Geen stops op deze rit + Stop verwijderd + Tik om te verwijderen + Details diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 38f6b230..7644c00b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -313,8 +313,7 @@ Zamknięte na stałe Zapisane Ostatnie - Ostatnie wyszukiwania - Wyczyść ostatnie wyszukiwania + Wyczyść ostatnie Szukaj miejsc, adresów i kategorii. Ustaw adres %1$s Edytuj %1$s @@ -550,4 +549,30 @@ Brakuje części modelu głosowego. Pobierz go ponownie w Ustawienia \u2192 Wyszukiwanie. Ten telefon nie udostępnił mikrofonu Veli. Zamknij inne aplikacje nagrywające dźwięk i spróbuj ponownie. Nagrywanie nieoczekiwanie się zatrzymało. Spróbuj ponownie. + + Szukaj + Brak zapisanych lub ostatnich celów + Wpisz, aby wyszukać cel + Jedź + Zakończ + Wycisz + Włącz dźwięk + Szybciej −%1$d min + Brak trasy, sprawdź lokalizację + Nic nie usłyszano + Dodano przystanek + Kategorie + Alerty podczas jazdy + Alerty o kamerach + Głosowe ostrzeżenie przy kamerze tablic. Tylko Android Auto. + Ostrzeżenie o prędkości + Wskaźnik prędkości robi się czerwony powyżej limitu. + Kamera przed tobą + Parking + Ładowanie EV + Przystanki + Brak przystanków w tej podróży + Przystanek usunięty + Dotknij, aby usunąć + Szczegóły diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3916c288..76ec790a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -311,8 +311,7 @@ Fechado permanentemente Guardados Recentes - Pesquisas recentes - Limpar pesquisas recentes + Limpar recentes Pesquise locais, moradas e categorias. Definir morada de %1$s Editar %1$s @@ -542,4 +541,30 @@ Falta parte do modelo de voz. Transfere-o novamente em Definições \u2192 Pesquisa. Este telemóvel não abriu o microfone para o Vela. Fecha outras apps que gravem áudio e tenta de novo. A gravação parou inesperadamente. Tenta de novo. + + Pesquisar + Ainda não há destinos salvos ou recentes + Digite para pesquisar um destino + Ir + Terminar + Silenciar + Ativar som + Mais rápido −%1$d min + Sem rota, verifique a localização + Nada foi entendido + Parada adicionada + Categorias + Alertas de condução + Alertas de câmaras + Aviso falado perto de uma câmara de matrículas. Só Android Auto. + Aviso de velocidade + O indicador de velocidade fica vermelho acima do limite. + Câmara à frente + Estacionamento + Carregamento EV + Paradas + Sem paradas nesta viagem + Parada removida + Toque para remover + Detalhes diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 46dc17c8..e2fd13e5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,8 +313,7 @@ Закрыто навсегда Сохранённые Недавние - Недавние запросы - Очистить недавние запросы + Очистить недавние Ищите места, адреса и категории. Задать адрес: %1$s Изменить %1$s @@ -550,4 +549,30 @@ Часть голосовой модели отсутствует. Скачайте её заново в Настройках \u2192 Поиск. Этот телефон не открыл микрофон для Vela. Закройте другие приложения, записывающие звук, и попробуйте снова. Запись неожиданно прервалась. Попробуйте снова. + + Поиск + Пока нет сохранённых или недавних пунктов назначения + Введите текст для поиска пункта назначения + Ехать + Завершить + Без звука + Включить звук + Быстрее −%1$d мин + Маршрут недоступен, проверьте местоположение + Речь не распознана + Остановка добавлена + Категории + Оповещения в пути + Оповещения о камерах + Голосовое предупреждение возле камеры номеров. Только Android Auto. + Предупреждение о скорости + Значок скорости краснеет при превышении лимита. + Впереди камера + Парковка + Зарядка ЭМ + Остановки + В этой поездке нет остановок + Остановка удалена + Нажмите, чтобы удалить + Подробности diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 9046109f..fff5697c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -311,8 +311,7 @@ Permanent stängt Sparade Senaste - Senaste sökningar - Rensa senaste sökningar + Rensa senaste Sök efter platser, adresser och kategorier. Ange adress för %1$s Redigera %1$s @@ -542,4 +541,30 @@ En del av röstmodellen saknas. Ladda ner den igen under Inställningar \u2192 Sök. Telefonen öppnade inte mikrofonen för Vela. Stäng andra appar som spelar in ljud och försök igen. Inspelningen stoppades oväntat. Försök igen. + + Sök + Inga sparade eller senaste destinationer än + Skriv för att söka efter en destination + Kör + Avsluta + Ljud av + Ljud på + Snabbare −%1$d min + Ingen rutt, kontrollera platsen + Uppfattade inget + Stopp tillagt + Kategorier + Körvarningar + Kameravarningar + Röstvarning nära en registreringsskyltkamera. Endast Android Auto. + Fortkörningsvarning + Hastighetsmärket blir rött över gränsen. + Kamera framför + Parkering + EV-laddning + Stopp + Inga stopp på den här resan + Stopp borttaget + Tryck för att ta bort + Detaljer diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 922e030c..6fda4171 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -313,8 +313,7 @@ Закрито назавжди Збережені Нещодавні - Нещодавні пошуки - Очистити нещодавні пошуки + Очистити нещодавні Шукайте місця, адреси та категорії. Задати адресу «%1$s» Редагувати «%1$s» @@ -550,4 +549,30 @@ Частина голосової моделі відсутня. Завантажте її знову в Налаштуваннях \u2192 Пошук. Цей телефон не відкрив мікрофон для Vela. Закрийте інші додатки, що записують звук, і спробуйте ще раз. Запис несподівано зупинився. Спробуйте ще раз. + + Пошук + Ще немає збережених або нещодавніх пунктів призначення + Введіть, щоб знайти пункт призначення + Їхати + Завершити + Без звуку + Увімкнути звук + Швидше −%1$d хв + Немає маршруту, перевірте місцезнаходження + Мовлення не розпізнано + Зупинку додано + Категорії + Сповіщення в дорозі + Сповіщення про камери + Голосове попередження біля камери номерів. Лише Android Auto. + Попередження про швидкість + Значок швидкості червоніє при перевищенні ліміту. + Попереду камера + Парковка + Зарядка EV + Зупинки + У цій поїздці немає зупинок + Зупинку видалено + Торкніться, щоб видалити + Деталі diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 60c36038..5c152630 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -524,8 +524,7 @@ 刪除 目前路況 4.0★ - 最近搜尋 - 清除最近搜尋 + 清除最近記錄 協助改進 Vela? 兩者都保留在您的手機上。除非您匯出,否則不會傳送任何內容。可隨時在設定中變更。 分享診斷資訊 @@ -562,4 +561,30 @@ 語音模型缺少部分檔案。請在設定 \u2192 搜尋 中重新下載。 這部手機未為 Vela 開啟麥克風。請關閉其他正在錄音的應用程式後重試。 錄音意外中斷。請重試。 + + 搜尋 + 還沒有已儲存或最近的目的地 + 輸入以搜尋目的地 + 出發 + 結束 + 靜音 + 取消靜音 + 更快 −%1$d 分鐘 + 沒有可用路線 — 請檢查位置 + 未能辨識語音 + 已新增停靠站 + 類別 + 駕駛提醒 + 攝影機提醒 + 接近車牌攝影機時語音提示。僅限Android Auto。 + 超速警告 + 超過速限時車內速度標誌變紅。 + 前方有攝影機 + 停車場 + 電動車充電 + 停靠站 + 本次行程沒有停靠站 + 已刪除停靠站 + 點按即可刪除 + 詳細資料 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2e3ddba8..089b67cb 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -524,8 +524,7 @@ 删除 当前路况 4.0★ - 最近搜索 - 清除最近搜索 + 清除最近记录 帮助改进 Vela? 两者都保留在您的手机上。除非您导出,否则不会发送任何内容。可随时在设置中更改。 共享诊断信息 @@ -562,4 +561,30 @@ 语音模型缺少部分文件。请在设置 \u2192 搜索 中重新下载。 这部手机未为 Vela 打开麦克风。请关闭其他正在录音的应用后重试。 录音意外中断。请重试。 + + 搜索 + 还没有已保存或最近的目的地 + 输入以搜索目的地 + 出发 + 结束 + 静音 + 取消静音 + 更快 −%1$d 分钟 + 没有可用路线 — 请检查位置 + 未能识别语音 + 已添加停靠点 + 类别 + 驾驶提醒 + 摄像头提醒 + 接近车牌摄像头时语音提示。仅限Android Auto。 + 超速警告 + 超过限速时车内速度标志变红。 + 前方有摄像头 + 停车场 + 电动车充电 + 停靠点 + 本次行程没有停靠点 + 已删除停靠点 + 点按即可删除 + 详情 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37569db3..d993eff8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,7 @@ Light/Dark applies to Vela only. It won\'t touch your phone\'s system theme. Map style OpenFreeMap (the default) is keyless and detailed. + Map data © OpenStreetMap contributors · tiles OpenFreeMap / OpenMapTiles Units Imperial (miles, feet) Metric (kilometers, meters) @@ -356,8 +357,7 @@ Permanently closed Saved Recent - Recent searches - Clear recent searches + Clear recents Search for places, addresses and categories. Set %1$s address Edit %1$s @@ -590,4 +590,30 @@ Move here See other dates Toggle full screen + + Search + No saved or recent destinations yet + Type to search for a destination + Go + End + Mute + Unmute + Faster −%1$d min + No route available — check your location + Didn\'t catch that + Stop added + Categories + Driving alerts + Camera alerts + Spoken heads-up near a license-plate camera. Android Auto only. + Speeding warning + The car speed badge turns red over the posted limit. + Camera ahead + Parking + EV charging + Stops + No stops on this trip + Stop removed + Tap to remove + Details diff --git a/core/src/main/java/app/vela/core/data/RecentPlaceStore.kt b/core/src/main/java/app/vela/core/data/RecentPlaceStore.kt index 590b9bb4..f44996e5 100644 --- a/core/src/main/java/app/vela/core/data/RecentPlaceStore.kt +++ b/core/src/main/java/app/vela/core/data/RecentPlaceStore.kt @@ -3,35 +3,71 @@ package app.vela.core.data import android.content.Context import app.vela.core.model.SavedPlace import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton -/** Recently-opened places (most-recent first, deduped by id, capped) - so the - * search page can offer one-tap return to a place you just looked at. */ +/** One recently-opened place. [at] = epoch ms it was last opened, so the search page + * can interleave places with recent search queries in ONE chronological list + * (Google mixes them; the icon tells them apart). */ +@Serializable +data class RecentPlace(val place: SavedPlace, val at: Long = 0) + +/** Recently-opened places (most-recent first, deduped by id, capped) — so the + * search page can offer one-tap return to a place you just looked at. */ @Singleton class RecentPlaceStore @Inject constructor( @ApplicationContext context: Context, ) { private val prefs = context.getSharedPreferences("vela_recent_places", Context.MODE_PRIVATE) - fun recent(): List = - runCatching { Json.decodeFromString>(prefs.getString(KEY, "[]") ?: "[]") } - .getOrDefault(emptyList()) + // ignoreUnknownKeys: a newer build's extra field must not fail the decode here, + // or the getOrDefault(empty) wipes the data on the next write (see PlaceListStore). + private val json = Json { ignoreUnknownKeys = true } + + fun recent(): List { + prefs.getString(KEY2, null)?.let { raw -> + runCatching { json.decodeFromString>(raw) }.getOrNull()?.let { return it } + } + return migrateLegacy() + } /** Record [place] as most-recent (moving it up if already present). */ fun add(place: SavedPlace) { if (place.name.isBlank()) return - val updated = (listOf(place) + recent().filterNot { it.id == place.id }).take(CAP) - prefs.edit().putString(KEY, Json.encodeToString(updated)).apply() + val updated = ( + listOf(RecentPlace(place, System.currentTimeMillis())) + + recent().filterNot { it.place.id == place.id } + ).take(CAP) + prefs.edit().putString(KEY2, json.encodeToString(updated)).apply() } - fun clear() = prefs.edit().remove(KEY).apply() + /** Remove one place (the X on its row). */ + fun remove(placeId: String) = + prefs.edit().putString(KEY2, json.encodeToString(recent().filterNot { it.place.id == placeId })).apply() + + fun clear() = prefs.edit().remove(KEY).remove(KEY2).apply() + + /** Pre-timestamp data was a bare SavedPlace list under [KEY]. Read it once, synthesize + * descending timestamps (a minute apart, anchored now) so old entries keep their + * order in the merged list, and persist under [KEY2]. [KEY] itself is left alone + * so a DOWNGRADED build still finds its old-format data instead of wiping it. */ + private fun migrateLegacy(): List { + val legacy = runCatching { json.decodeFromString>(prefs.getString(KEY, "[]") ?: "[]") } + .getOrDefault(emptyList()) + if (legacy.isEmpty()) return emptyList() + val now = System.currentTimeMillis() + val migrated = legacy.mapIndexed { i, p -> RecentPlace(p, now - i * 60_000L) } + prefs.edit().putString(KEY2, json.encodeToString(migrated)).apply() + return migrated + } private companion object { - const val KEY = "places" + const val KEY = "places" // legacy bare SavedPlaces (kept in place for downgrades) + const val KEY2 = "places2" // timestamped entries const val CAP = 8 } } diff --git a/core/src/main/java/app/vela/core/data/RecentSearchStore.kt b/core/src/main/java/app/vela/core/data/RecentSearchStore.kt index f11afb03..b2c00e74 100644 --- a/core/src/main/java/app/vela/core/data/RecentSearchStore.kt +++ b/core/src/main/java/app/vela/core/data/RecentSearchStore.kt @@ -2,12 +2,19 @@ package app.vela.core.data import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton +/** One remembered search query. [at] = epoch ms it was last used, so the search page + * can interleave queries with recently-opened places in ONE chronological list + * (Google mixes them; the icon tells them apart). */ +@Serializable +data class RecentQuery(val query: String, val at: Long = 0) + /** Most-recent-first list of search queries, persisted locally (capped). */ @Singleton class RecentSearchStore @Inject constructor( @@ -15,21 +22,50 @@ class RecentSearchStore @Inject constructor( ) { private val prefs = context.getSharedPreferences("vela_recents", Context.MODE_PRIVATE) - fun recent(): List = - runCatching { Json.decodeFromString>(prefs.getString(KEY, "[]") ?: "[]") } - .getOrDefault(emptyList()) + // ignoreUnknownKeys: kept identical to the other stores so a future model change + // cannot make the decode throw and the getOrDefault(empty) wipe the data. + private val json = Json { ignoreUnknownKeys = true } + + fun recent(): List { + prefs.getString(KEY2, null)?.let { raw -> + runCatching { json.decodeFromString>(raw) }.getOrNull()?.let { return it } + } + return migrateLegacy() + } fun add(query: String) { val q = query.trim() if (q.isEmpty()) return - val updated = (listOf(q) + recent().filterNot { it.equals(q, ignoreCase = true) }).take(MAX) - prefs.edit().putString(KEY, Json.encodeToString(updated)).apply() + val updated = ( + listOf(RecentQuery(q, System.currentTimeMillis())) + + recent().filterNot { it.query.equals(q, ignoreCase = true) } + ).take(MAX) + prefs.edit().putString(KEY2, json.encodeToString(updated)).apply() } - fun clear() = prefs.edit().remove(KEY).apply() + /** Remove one query (the X on its row). */ + fun remove(query: String) = + prefs.edit().putString(KEY2, json.encodeToString(recent().filterNot { it.query.equals(query, ignoreCase = true) })).apply() + + fun clear() = prefs.edit().remove(KEY).remove(KEY2).apply() + + /** Pre-timestamp data was a bare string list under [KEY]. Read it once, synthesize + * descending timestamps (a minute apart, anchored now) so old entries keep their + * order in the merged list, and persist under [KEY2]. [KEY] itself is left alone + * so a DOWNGRADED build still finds its old-format data instead of wiping it. */ + private fun migrateLegacy(): List { + val legacy = runCatching { json.decodeFromString>(prefs.getString(KEY, "[]") ?: "[]") } + .getOrDefault(emptyList()) + if (legacy.isEmpty()) return emptyList() + val now = System.currentTimeMillis() + val migrated = legacy.mapIndexed { i, q -> RecentQuery(q, now - i * 60_000L) } + prefs.edit().putString(KEY2, json.encodeToString(migrated)).apply() + return migrated + } private companion object { - const val KEY = "queries" + const val KEY = "queries" // legacy bare strings (kept in place for downgrades) + const val KEY2 = "queries2" // timestamped entries const val MAX = 8 } } diff --git a/core/src/main/java/app/vela/core/model/Geo.kt b/core/src/main/java/app/vela/core/model/Geo.kt index d313de95..0da00c41 100644 --- a/core/src/main/java/app/vela/core/model/Geo.kt +++ b/core/src/main/java/app/vela/core/model/Geo.kt @@ -10,7 +10,7 @@ import kotlin.math.sqrt /** * A WGS84 coordinate. Vela's own type, deliberately free of any MapLibre - * dependency so `:core` stays UI-agnostic - convert to/from + * dependency so `:core` stays UI-agnostic — convert to/from * `org.maplibre.android.geometry.LatLng` only at the view boundary. */ data class LatLng(val lat: Double, val lng: Double) @@ -26,7 +26,7 @@ data class LatLngBounds( companion object { /** Rough square bounds of [radiusMeters] around [c]; good enough for a - * "search near here" viewport bias. */ + * "search near here" viewport bias. */ fun around(c: LatLng, radiusMeters: Double): LatLngBounds { val dLat = radiusMeters / 111_320.0 val dLng = radiusMeters / @@ -37,7 +37,7 @@ data class LatLngBounds( } /** Great-circle distance in metres (haversine). Used for "near me" ranking and - * the nav engine's off-route / approaching-maneuver checks. */ + * the nav engine's off-route / approaching-maneuver checks. */ fun LatLng.distanceTo(o: LatLng): Double { val r = 6_371_000.0 val dLat = Math.toRadians(o.lat - lat) @@ -56,8 +56,8 @@ fun LatLng.bearingTo(o: LatLng): Double { return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0 } -/** The point [meters] away from this one along [bearingDeg] (0 = N, 90 = E) - great-circle - * forward projection. Used by the Street View address-street probe (upstream, ported 2026-07-16). */ +/** The point [meters] away from this one along [bearingDeg] (0 = N, 90 = E) — great-circle + * forward projection. Used to look AHEAD of the nav puck so it sits low in the car map view. */ fun LatLng.destinationPoint(meters: Double, bearingDeg: Double): LatLng { val r = 6_371_000.0 val d = meters / r diff --git a/core/src/main/java/app/vela/core/nav/NavSession.kt b/core/src/main/java/app/vela/core/nav/NavSession.kt index c88dd7f3..e524d668 100644 --- a/core/src/main/java/app/vela/core/nav/NavSession.kt +++ b/core/src/main/java/app/vela/core/nav/NavSession.kt @@ -216,8 +216,34 @@ class NavSession @Inject constructor( passedStops = 0 remaining } - voice.speak(app.vela.core.i18n.NavStringsRegistry.current().rerouting(), interrupt = true) diag.record("nav", "add stop mid-nav → ${stop.label}") + replanVia(newRemaining, loc, dest) + } + + /** The remaining (unpassed) stops in travel order - the car's stop manager lists these. */ + fun remainingStops(): List = synchronized(stopLock) { stops.drop(passedStops) } + + /** [addStop]'s inverse: drop one remaining stop (matched by location+label) and replan through + * the rest. Same user-ordered reroute semantics - no cooldown, cancels any deviation reroute. */ + fun removeStop(stop: NavStop, loc: LatLng) { + val dest = destination ?: return + val newRemaining = synchronized(stopLock) { + val remaining = stops.drop(passedStops) + .filterNot { it.location == stop.location && it.label == stop.label } + stops = remaining + stopMarks = List(remaining.size) { null } + passedStops = 0 + remaining + } + diag.record("nav", "remove stop mid-nav → ${stop.label}") + replanVia(newRemaining, loc, dest) + } + + /** The shared user-ordered replan behind [addStop]/[removeStop]: fetch through the new stop + * plan, adopt atomically. The plan is already committed (marks null until the route lands), so + * even a failed fetch keeps it - the next reroute/recheck routes through it. */ + private fun replanVia(newRemaining: List, loc: LatLng, dest: LatLng) { + voice.speak(app.vela.core.i18n.NavStringsRegistry.current().rerouting(), interrupt = true) val gen = sessionGen rerouteJob?.cancel() rerouteJob = scope.launch { diff --git a/core/src/main/java/app/vela/core/voice/CarCommands.kt b/core/src/main/java/app/vela/core/voice/CarCommands.kt new file mode 100644 index 00000000..48d058ea --- /dev/null +++ b/core/src/main/java/app/vela/core/voice/CarCommands.kt @@ -0,0 +1,75 @@ +package app.vela.core.voice + +/** + * A tiny offline command grammar over a voice transcript, for the Android Auto mic: "navigate + * home", "mute", "end navigation" become ACTIONS instead of falling through to a place search + * for the literal words. Everything unrecognised is a [Command.Search] with the destination + * phrase stripped of its leading verb ("navigate to the nearest gas station" searches "the + * nearest gas station"), so a plain search is always the safe fallback - the grammar can only + * ever upgrade a transcript, never lose one. + * + * English-only for now (Whisper's small models are strongest in English and the phrase tables + * are trivially extendable per locale); a non-English transcript simply searches as before. + */ +object CarCommands { + + sealed interface Command { + data object GoHome : Command + data object GoWork : Command + data object FindMyCar : Command + data object Mute : Command + data object Unmute : Command + data object EndNav : Command + data class Search(val query: String) : Command + } + + // Leading verb phrases a destination request starts with, longest-first so "navigate to" + // wins over "navigate" (sorted at init so a new entry can't silently break precedence). + // NO bare verbs here: "drive" stripped "drive in movie theater" to "in movie theater" and + // "take me" maimed "take me out diner" (review finding) - the same trap the bare-"go" comment + // below documents. Verb-only phrasings of home/work are covered as whole phrases instead. + private val NAV_PREFIXES = listOf( + "navigate to", "navigate me to", "navigate", "take me to", "drive to", + "drive me to", "directions to", "go to", "route to", "head to", "bring me to", + ).sortedByDescending { it.length } + + // "go home" is listed whole rather than via a bare "go" prefix - stripping "go" would maim + // legitimate searches ("go kart track" must not search "kart track"). + private val HOME_WORDS = setOf( + "home", "my home", "my house", "the house", "go home", "take me home", + "drive home", "drive me home", + ) + private val WORK_WORDS = setOf("work", "my work", "my office", "the office") + private val CAR_PHRASES = setOf( + "find my car", "where is my car", "where's my car", "where did i park", + "find the car", "my parked car", "parked car", + ) + private val MUTE_PHRASES = setOf("mute", "mute voice", "be quiet", "silence", "stop talking") + private val UNMUTE_PHRASES = setOf("unmute", "unmute voice", "speak", "voice on", "talk to me") + private val END_PHRASES = setOf( + "end navigation", "stop navigation", "cancel navigation", "end the navigation", + "stop the navigation", "cancel the route", "end route", "stop routing", "end the trip", + "stop nav", "end nav", "cancel nav", + ) + + fun parse(transcript: String): Command { + val t = transcript.trim().trimEnd('.', '!', '?').lowercase() + if (t.isEmpty()) return Command.Search(transcript) + + if (t in CAR_PHRASES) return Command.FindMyCar + if (t in MUTE_PHRASES) return Command.Mute + if (t in UNMUTE_PHRASES) return Command.Unmute + if (t in END_PHRASES) return Command.EndNav + + // "navigate home" / bare "home": destination words after an (optional) nav verb. + val stripped = NAV_PREFIXES.firstOrNull { t.startsWith("$it ") } + ?.let { t.removePrefix("$it ").trim() } ?: t + if (stripped in HOME_WORDS) return Command.GoHome + if (stripped in WORK_WORDS) return Command.GoWork + if (stripped in CAR_PHRASES) return Command.FindMyCar + + // A stripped verb means the rest is the destination; otherwise search the transcript as-is + // (original casing - the geocoder does better with it than with a lowercased copy). + return if (stripped != t) Command.Search(stripped) else Command.Search(transcript.trim()) + } +} diff --git a/core/src/test/java/app/vela/core/voice/CarCommandsTest.kt b/core/src/test/java/app/vela/core/voice/CarCommandsTest.kt new file mode 100644 index 00000000..88012ee0 --- /dev/null +++ b/core/src/test/java/app/vela/core/voice/CarCommandsTest.kt @@ -0,0 +1,60 @@ +package app.vela.core.voice + +import app.vela.core.voice.CarCommands.Command +import org.junit.Assert.assertEquals +import org.junit.Test + +class CarCommandsTest { + + @Test fun `navigate home in all its forms`() { + listOf("navigate home", "Navigate home.", "take me home", "go home", "drive to my house", "home").forEach { + assertEquals(it, Command.GoHome, CarCommands.parse(it)) + } + } + + @Test fun `work variants`() { + listOf("navigate to work", "go to my office", "work").forEach { + assertEquals(it, Command.GoWork, CarCommands.parse(it)) + } + } + + @Test fun `find my car`() { + listOf("find my car", "Where's my car?", "where did I park").forEach { + assertEquals(it, Command.FindMyCar, CarCommands.parse(it)) + } + } + + @Test fun `mute unmute end`() { + assertEquals(Command.Mute, CarCommands.parse("Mute")) + assertEquals(Command.Mute, CarCommands.parse("be quiet")) + assertEquals(Command.Unmute, CarCommands.parse("unmute")) + assertEquals(Command.EndNav, CarCommands.parse("end navigation")) + assertEquals(Command.EndNav, CarCommands.parse("Stop the navigation.")) + } + + @Test fun `verb stripped from a destination search`() { + assertEquals(Command.Search("the nearest gas station"), CarCommands.parse("navigate to the nearest gas station")) + assertEquals(Command.Search("central park"), CarCommands.parse("take me to central park")) + assertEquals(Command.Search("starbucks"), CarCommands.parse("directions to starbucks")) + } + + @Test fun `plain searches pass through with original casing`() { + assertEquals(Command.Search("Coffee shops near me"), CarCommands.parse("Coffee shops near me")) + assertEquals(Command.Search("Madis Coffee Roasters"), CarCommands.parse("Madis Coffee Roasters")) + } + + @Test fun `a place merely CONTAINING a keyword is not a command`() { + // "Home Depot" must stay a search - only an exact keyword (after verb stripping) commands. + assertEquals(Command.Search("Home Depot"), CarCommands.parse("Home Depot")) + assertEquals(Command.Search("workshop cafe"), CarCommands.parse("navigate to workshop cafe")) + // Bare verbs must NOT strip: these are place names, not commands (review finding). + assertEquals(Command.Search("drive in movie theater"), CarCommands.parse("drive in movie theater")) + assertEquals(Command.Search("take me out diner"), CarCommands.parse("take me out diner")) + assertEquals(Command.Search("Mute Swan Pub"), CarCommands.parse("Mute Swan Pub")) + } + + @Test fun `home as a destination of a nav verb still commands`() { + assertEquals(Command.GoHome, CarCommands.parse("navigate to home")) + assertEquals(Command.GoWork, CarCommands.parse("drive to the office")) + } +} diff --git a/docs/ANDROID-AUTO.md b/docs/ANDROID-AUTO.md index ac31b5d9..b8a14b99 100644 --- a/docs/ANDROID-AUTO.md +++ b/docs/ANDROID-AUTO.md @@ -26,6 +26,59 @@ needs. Media apps (music, podcasts) do **not** need it, which is why a sideloade music player shows up on Android Auto with no fuss while a nav app does not. See below. +## What works in the car + +- **Browse map** with the styled Vela basemap, puck, camera follow, pan/zoom. + At night (the car's own day/night signal) the map switches to the real dark + palette - the same one the phone's dark theme uses - not a darkened filter. +- **Search** (keyboard on the head unit) with results biased to your location; + tapping a result opens the route preview. +- **Voice, honoring your engine preference** (Settings, Search): with Vela's + on-device model pinned (or Auto with it installed), the mic buttons record + through the car's microphone into on-device Whisper - and if the car mic + turns out to be silent (common on aftermarket units), retry once on the + phone's mic, still fully on-device. With the system engine pinned, the mic + lands you on the search surface whose in-field mic is the system recognizer + (the platform reserves direct voice-session starts for Hey Google / the + wheel button). Speech is never routed against the pin. Spoken commands - + "navigate home", "find my car", "mute", "end navigation" - act in place; + anything else loads results directly. +- **Route preview** with live-traffic alternates, **turn-by-turn** with maneuver + icons + lane guidance, and instrument-cluster trip updates. +- **Search along the route while driving**: the nav screen carries a search + action and the mic; results are corridor-filtered (the same filter the phone + uses) and tapping one adds it as a stop - the drive replans through it. A + **stop manager** on the nav map strip lists the remaining stops (tap to + remove; the drive replans). One-tap **category shortcuts** (Gas / Food / + Coffee / Groceries / Parking / EV charging) sit behind the grid action on + the landing screen and feed the same flow in-drive. +- **Place details** from a search result's route preview (rating, address, + category, phone) - a pane the host expands fully while parked. +- **Live traffic on the browse map** when the phone's traffic overlay is on - + the same keyless tiles, same subdued opacity. +- **Voice commands**: the mic understands "navigate home", "take me to work", + "find my car", "mute" / "unmute", and "end navigation" (English); anything + else is a normal search with the leading verb stripped ("navigate to the + nearest gas station" searches the destination, not the whole sentence). +- **Faster-route offers pop as a head-up alert** (Car API 5+) with Go/Done, + not just a strip button; the strip button remains for older hosts. +- **Parking**: arriving shows a card with one-tap "Save parking spot" (shared + with the phone's Park feature), and a saved spot adds a "Find my car" row to + the landing screen. **Home/Work rows show live drive times.** +- **Resume**: if navigation is already running when the car connects, the car + UI lands straight in guidance. +- **Driving alerts** (Settings, Navigation, Driving alerts - both off by + default): a spoken heads-up approaching a mapped license-plate camera + (bundled DeFlock data, offline), and the speed badge turning red over the + posted limit. +- **Assistant / `geo:` intents**: "navigate to X" style NAVIGATE intents open + the route preview directly. Coordinates (`geo:lat,lng`, + `geo:0,0?q=lat,lng(Label)`) go straight there; a **free-text** destination + (`geo:0,0?q=central park`) is geocoded through Vela's own search, biased to + your location, and lands in the preview for the top hit (a miss shows a + toast). Upstream leaves free-text destinations unhandled; the geocoding is a + fork addition. + ## Why a sideloaded music app "just works" but a nav app does not They ride two completely different integrations: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1fdae1e6..d12ba430 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -21,6 +21,27 @@ Status legend: [x] done · [~] partial / in progress · [ ] planned - [x] **Closing a far-away place keeps the camera there** - a programmatic jump >1 km from your fix drops free-drive follow like a manual pan, so closing the sheet no longer glides the map home; the locate button re-arms follow. - [x] **Compact nav banner on small screens** - under 500dp of screen height the maneuver banner shrinks (36dp glyph, smaller distance type, tighter padding) and every text line is capped, so a long arrive card can never bury the map (issue #41, F21 Pro). - [x] **Settings reorganized + nav UI refresh.** Sections in usage order (Appearance, Map, Place pages, Navigation, Voice, Offline, ...); vibrate-on-turns is one row of per-mode chips; the in-drive banner and bottom bar are soft-cornered floating cards. +- [x] **Android Auto: full car-side navigation (2026-07-23, ported upstream ac487f78 + + follow-ups).** Search, route preview with alternates, and turn-by-turn with maneuver icons and + lane guidance on the head unit; the real styled Vela map drawn via MapSnapshotter with a + smoothed puck; assistant/geo: NAVIGATE intents; instrument-cluster trip updates. Roboto basemap + glyphs app-wide (MapFonts), and recents unified into one chronological timeline shared by phone + and car. Requires androidx.car.app:app-projected - the missing artifact that had kept Vela + invisible to Android Auto entirely. Two fork additions on top of the port: **in-car voice + search** - a mic action on the car search screen (Car API 5+ hosts) records from the car's own + microphone and transcribes with the same on-device Whisper pipeline as the phone mic, so speech + never leaves the device; and **free-text NAVIGATE geocoding** - "navigate to central park" from + the assistant resolves through Vela's own search (biased to your location) straight into the + route preview, where upstream leaves free-text destinations unhandled. A second fork batch + (2026-07-23) rounds out the drive: **search along route** from the nav screen (mic included) with + corridor-filtered results whose pick becomes a stop, one-tap **category shortcuts** + (Gas/Food/Coffee/Groceries), offline **voice commands** ("navigate home", "mute", "end + navigation" - unit-tested grammar, unmatched phrases fall through to search), faster-route + offers as **head-up alerts**, an **arrival card with one-tap parking save** + a "Find my car" + landing row (shared with the phone's Park feature), **live Home/Work drive times**, **resume + into guidance** when the car connects mid-drive, and optional **driving alerts** (spoken + license-plate-camera heads-up from the bundled DeFlock data; red speed badge over the limit - + Settings, Navigation, Driving alerts, both off by default). - [x] **Settings redesigned hub-and-spoke (2026-07-22).** The single extremely long page became a short hub of category rows (icon + title + one-line subtitle), each opening its own small sub-screen - on a keypad phone any section is a few DOWN presses + OK away instead of ~70 presses down one scroll. Every page shares one `SettingsScaffold` (Back auto-focus, the two focus bridges, the horizontal swallow); BACK peels spoke -> hub -> map and the hub re-focuses the row you came back from; the restricted flavor hides the Place pages row; the onboarding offline prompt deep-links into the Offline spoke. A voice download in flight shows its progress on the hub's Voice row. The spokes use a standard flat settings layout: accent section heads, hairline separators, every toggle's hint as a short line inside its row (33 verbose hint strings were cut to one line across all 15 locales), compact 44dp interactive sizing, and single-line-safe button rows (a squeezed label wraps as a whole pill, never as two-line button text). The hub rows carry thin outline borders and the title bar separates via the neutral elevated surface role. New app-wide true-black theme (Appearance -> Black; the AMOLED mode): the dark scheme on true-black surfaces; light mode moved off pure white onto soft teal-cast off-whites. - [x] **Arrive step names the destination.** The banner, step list and arrival card show the business name and address, falling back to address then coordinates. - [x] **Perf audit batch 2: upload churn off the render thread (upstream 07d3f315, applicable slices).** applyData's tail is identity-gated like its head (alternates, location dot, preview pin, browse gradient, me-layer properties, ensure* style probes all skip when nothing changed; every gate resets on style reload); route-mode visibility flips run once per transition; ambient POI uploads defer while a camera flight is in the air and flush at landing; the in-nav route split re-uploads only a 3 km leading window per tick with the far tail on a twin layer (also sharpens the traffic gradient near the puck); the speed-limit poll skips during flights and pinches; and the browse follow's cold engage from a far-out camera is one owned flight instead of a teleport (f95f0b96's fix, applicable now that the fork has the browse ticker). Upstream's walk-dot regen and result-pin bitmap cache patch machinery the fork does not have (image-dash walking dots, no result-pin rasterizer). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cbb2ac07..3987d38d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,3 +110,9 @@ sandboxed JS parse-logic with no app update (see SPEC section 5). Fix future bre the MTK flip-phone targets). Verification = the full golden matrix, both themes; the calibrated relationships to keep intact are the teal containers (stock defaults read purple) and the orange 0xFFFF6D00 focus ring's contrast against them. + +- **Mirror the map-fonts glyph release.** MapFonts currently points at upstream's GitHub Pages + glyph host (probe-guarded, so failure degrades to Noto). Run scripts/build-map-fonts.sh, publish + a map-fonts release on THIS repo, serve it from this repo's Pages, and flip MAP_FONTS_URL. +- **A DHU-based Android Auto harness phase.** AA is the one surface with no scripted gate; the + Desktop Head Unit emulator can drive and screenshot the car screens. diff --git a/docs/SPEC.md b/docs/SPEC.md index d0c4a79e..c09b39c9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -295,10 +295,36 @@ Two layers. - **Navigation**: foreground `NavigationService` + shared `@Singleton NavSession` (survives backgrounding/screen-off, persistent notification, ~2-min faster-route re-check, arrival summary). - Spoken `VoiceGuide` (AOSP TTS, best offline voice) + direction-coded `Haptics`. Heading-up tilted camera, blue-dot + heading cone. - `NavEngine` (pure, unit-tested) tracks **monotonic forward progress** (`NavState.traveledM`, windowed projection in `projectAlong` - not global-nearest) so "remaining" and "distance to next turn" stay **along-route** and honest on routes that pass near themselves (switchback / cloverleaf / out-and-back); off-route it holds rather than snapping to a far leg. -- **Android Auto (first cut)**: `app/car/` - a NAVIGATION-category `CarAppService` (`VelaCarAppService`, host validator open because sideloads already require AA's "Unknown sources" developer switch) whose one screen (`CarMapScreen`) renders the live map on the car display and, while navigating, a RoutingInfo card with the current maneuver + distance from the same `@Singleton NavSession` the phone drives. - - Rendering: templated car apps get a raw surface, MapLibre needs a View, so the renderer wraps the surface in a `VirtualDisplay` + `Presentation` holding a plain `MapView`, styled with the same Liberty URI + `applyDark`/`applyLight` recolour keyed to the car's own day/night. - - Puck = its own AOSP `LocationManager` listener; pan/zoom = `onScroll`/`onScale` camera math; recenter + zoom actions in the strip. - - The phone app runs nav and voice; the car is a display for it. +- **Android Auto (full car-side nav, ported upstream)**: `app/car/` - a NAVIGATION-category + `CarAppService` (`VelaCarAppService`, Hilt-injected `:core` singletons bundled as `CarDeps`; + host validator open because sideloads already require AA's "Unknown sources" developer switch; + `androidx.car.app:app-projected` is REQUIRED - its merged `CarAppMetadataHolderService` is the + documented Android Auto glue and the cluster/NavigationManager APIs live in it). + - Screens: `MainCarScreen` (Home/Work/recents/saved) -> `SearchCarScreen` (car keyboard, same + `MapDataSource`) -> `RoutePreviewCarScreen` (alternates + live ETAs) -> `ActiveNavCarScreen` + (maneuver icons + lane guidance via `ManeuverMapper`/`LaneImage`, trip updates to the + instrument cluster). `VelaCarSession.onNewIntent` accepts assistant/`geo:` NAVIGATE intents + (opaque-URI-safe); a free-text `q=` destination geocodes via `MapDataSource.search` into the + route preview (fork addition - upstream leaves it unhandled). The phone still runs + `NavSession` and speaks; the car adds no nav logic. + - Voice search (fork addition): `CarVoiceSearch` bridges the car's mic (`CarAudioRecord`, Car + API 5+) into `WhisperRecognizer.listen` via its `PcmSource` seam (16 kHz PCM16 on both), so + the mic actions (landing, search, and active-nav screens; shared flow in + `CarVoiceSearch.micAction`) run the same on-device VAD + Whisper pipeline as the phone. + Gated on host API level, the Settings voice toggle, and an installed model. Transcripts go + through `core/voice/CarCommands` first (offline grammar: navigate home/work, find my car, + mute/unmute, end navigation; fallback = search with the nav verb stripped). + - In-drive additions (fork): search-along-route (`SearchCarScreen(alongRoute)` + + `RouteCorridor`; a pick calls `NavSession.addStop`), `CategoriesCarScreen` grid, + faster-route head-up `Alert`, `ArrivalCarScreen` -> `ParkingStore` (+ Find-my-car landing + row, live Home/Work ETAs), resume-into-guidance, and `DriveAlerts` camera/speeding warnings + (bundled FlockCameras corridor scan in `VelaCarSession`; badge tint in `CarMapRenderer`). + - Rendering: `CarMapRenderer` draws the REAL styled basemap via MapLibre's `MapSnapshotter` + (off-screen -> Bitmap -> Canvas on the template surface; one shared renderer per session). + Nav mode is heading-up with look-ahead camera, speed-tightened zoom, traffic-coloured route + and a speed badge; browse is north-up; a ticker smooths puck/bearing between ~1 Hz fixes. + - Basemap glyphs are Roboto app-wide via `MapFonts` (probe-guarded patched Liberty style, + shared by the phone map and the car snapshotter). - **Place-content toggles**: Settings → Map, `ShowReviews` / `LoadPhotos` holders (default on). - Off gates BOTH the fetch (`fetchReviews`/`fetchPhotos` in the VM) and the render (review tab / photo strip in `PlaceSheet`), so off means no scrape traffic at all, not just hidden UI. - **"Hide adult categories" toggle**: Settings → Map, `HideAdult` holder (default **off**). diff --git a/docs/screenshots/aa/categories.png b/docs/screenshots/aa/categories.png new file mode 100644 index 00000000..de75bc87 Binary files /dev/null and b/docs/screenshots/aa/categories.png differ diff --git a/docs/screenshots/aa/home-mic-search.png b/docs/screenshots/aa/home-mic-search.png new file mode 100644 index 00000000..3d19703e Binary files /dev/null and b/docs/screenshots/aa/home-mic-search.png differ diff --git a/docs/screenshots/aa/indrive-search-categories.png b/docs/screenshots/aa/indrive-search-categories.png new file mode 100644 index 00000000..655a9149 Binary files /dev/null and b/docs/screenshots/aa/indrive-search-categories.png differ diff --git a/docs/screenshots/aa/search-local-mic.png b/docs/screenshots/aa/search-local-mic.png new file mode 100644 index 00000000..f4ed009d Binary files /dev/null and b/docs/screenshots/aa/search-local-mic.png differ diff --git a/docs/screenshots/aa/search.png b/docs/screenshots/aa/search.png new file mode 100644 index 00000000..da4348d7 Binary files /dev/null and b/docs/screenshots/aa/search.png differ diff --git a/docs/screenshots/aa/vela-voice-listening.png b/docs/screenshots/aa/vela-voice-listening.png new file mode 100644 index 00000000..81a33074 Binary files /dev/null and b/docs/screenshots/aa/vela-voice-listening.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7804b7aa..8a329f63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ detekt = "1.23.8" [libraries] androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" } androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" } +androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", version.ref = "carApp" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } diff --git a/tests/devices/full_coverage.sh b/tests/devices/full_coverage.sh index bc956f62..ba791122 100644 --- a/tests/devices/full_coverage.sh +++ b/tests/devices/full_coverage.sh @@ -131,8 +131,15 @@ cover_one() { # "coffee" in recents, so typing a prefix now surfaces it as a local (History-icon) match ABOVE the # network suggestions - instant, offline, no fetch. Best-effort: only assert when the recent exists. if [ "$haveResults" = 1 ]; then - goto_map; open_search; $ADB shell input text "cof" >/dev/null 2>&1; sleep 1.5 - if on_screen "coffee"; then mark "search-history-astype" 1; else mark "search-history-astype" 0; fi + goto_map; open_search; $ADB shell input text "cof" >/dev/null 2>&1 + # POLL (the IME + suggestion fetch race a single post-sleep dump) and match CASE-INSENSITIVE: + # run_coffee's chip path records its stable English query literal "Coffee", the typed fallback + # records "coffee" - the old exact-lowercase check systematically missed the chip path on every + # leg, on main and branch alike (A/B-confirmed 2026-07-23). + # Guard the ci-match's main false-pass vector: on the BARE MAP (open_search failed) the + # "Coffee" CHIP would satisfy a case-insensitive match with no history row anywhere. Require + # the typed field text first - it only exists inside the armed overlay. + if on_screen_within "cof" 4 && on_screen_ci_within "coffee" 8; then mark "search-history-astype" 1; else mark "search-history-astype" 0; fi key "$K_BACK" 1 else mark "search-history-astype" 0; fi fi diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/05-bare-map.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/05-bare-map.png index 8cb9cd2e..727eff49 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/05-bare-map.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/05-bare-map.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/06-search-overlay.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/06-search-overlay.png index 121f496f..f53d7bc5 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/06-search-overlay.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/06-search-overlay.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/07-search-results.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/07-search-results.png index a7c05417..a525fe74 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/07-search-results.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/07-search-results.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/08-search-history-astype.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/08-search-history-astype.png new file mode 100644 index 00000000..4124f60a Binary files /dev/null and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/08-search-history-astype.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/41-settings-top.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/41-settings-top.png index dc9d4289..c7092926 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/41-settings-top.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/41-settings-top.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/42-settings-appearance.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/42-settings-appearance.png index 16edddbf..e136db0f 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/42-settings-appearance.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/42-settings-appearance.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/43-settings-map.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/43-settings-map.png index 53bc4841..e49ff8a9 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/43-settings-map.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/43-settings-map.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/44-settings-place-pages.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/44-settings-place-pages.png index d55c4f99..74f61291 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/44-settings-place-pages.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/44-settings-place-pages.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/45-settings-navigation.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/45-settings-navigation.png index f1e35145..bdc13e04 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/45-settings-navigation.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/45-settings-navigation.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/46-settings-voice.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/46-settings-voice.png index 13e8d6c6..a96d6dca 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/46-settings-voice.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/46-settings-voice.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/47-settings-search.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/47-settings-search.png index 993dd0b6..d53bed8c 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/47-settings-search.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/47-settings-search.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/48-settings-asr-engines.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/48-settings-asr-engines.png index 993dd0b6..d53bed8c 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/48-settings-asr-engines.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/48-settings-asr-engines.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/49-settings-offline.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/49-settings-offline.png index 7c350e1a..5ac281cb 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/49-settings-offline.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/49-settings-offline.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/50-settings-saved-places.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/50-settings-saved-places.png index e0584226..17fc0132 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/50-settings-saved-places.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/50-settings-saved-places.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/51-settings-privacy.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/51-settings-privacy.png index 34956534..1ecf9a0a 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/51-settings-privacy.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/51-settings-privacy.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/52-settings-diagnostics.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/52-settings-diagnostics.png index 35e95528..52a8bab1 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/52-settings-diagnostics.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/52-settings-diagnostics.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/53-settings-about.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/53-settings-about.png index c23cd9d8..b2344cb3 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/53-settings-about.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/53-settings-about.png differ diff --git a/tests/devices/kyocera-duraxe-e4830/screenshots/full/54-settings-theme-light.png b/tests/devices/kyocera-duraxe-e4830/screenshots/full/54-settings-theme-light.png index c8d1d744..32f804c1 100644 Binary files a/tests/devices/kyocera-duraxe-e4830/screenshots/full/54-settings-theme-light.png and b/tests/devices/kyocera-duraxe-e4830/screenshots/full/54-settings-theme-light.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/05-bare-map.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/05-bare-map.png index 4e0c8d77..66410466 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/05-bare-map.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/05-bare-map.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/06-search-overlay.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/06-search-overlay.png index 89185456..a7cf1bb0 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/06-search-overlay.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/06-search-overlay.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/07-search-results.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/07-search-results.png index f212a2fa..106dd770 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/07-search-results.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/07-search-results.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/08-search-history-astype.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/08-search-history-astype.png index c6fb170b..4c136622 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/08-search-history-astype.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/08-search-history-astype.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/41-settings-top.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/41-settings-top.png index c7ab6ad0..839b8161 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/41-settings-top.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/41-settings-top.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/42-settings-appearance.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/42-settings-appearance.png index 96708749..a0a0cf8d 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/42-settings-appearance.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/42-settings-appearance.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/43-settings-map.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/43-settings-map.png index fe9f9193..50929188 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/43-settings-map.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/43-settings-map.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/44-settings-navigation.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/44-settings-navigation.png index f6a79a74..dfc65534 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/44-settings-navigation.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/44-settings-navigation.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/45-settings-voice.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/45-settings-voice.png index 360d8e31..5a486495 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/45-settings-voice.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/45-settings-voice.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/46-settings-search.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/46-settings-search.png index 4d64ae3c..63472aa1 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/46-settings-search.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/46-settings-search.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/47-settings-asr-engines.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/47-settings-asr-engines.png index 4d64ae3c..63472aa1 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/47-settings-asr-engines.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/47-settings-asr-engines.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/48-settings-offline.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/48-settings-offline.png index 72c99fc8..8ed57b0a 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/48-settings-offline.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/48-settings-offline.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/49-settings-saved-places.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/49-settings-saved-places.png index 794edc6d..34d0e827 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/49-settings-saved-places.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/49-settings-saved-places.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/50-settings-privacy.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/50-settings-privacy.png index 9a5945a1..e792a305 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/50-settings-privacy.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/50-settings-privacy.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/51-settings-diagnostics.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/51-settings-diagnostics.png index 6821d20b..c7791e57 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/51-settings-diagnostics.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/51-settings-diagnostics.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/52-settings-about.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/52-settings-about.png index 5bb047ee..6fa245eb 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/52-settings-about.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/52-settings-about.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/53-settings-theme-light.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/53-settings-theme-light.png index 74a883a1..d7c83525 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/53-settings-theme-light.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/53-settings-theme-light.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-restricted/54-settings-theme-amoled.png b/tests/devices/kyocera-e4810/screenshots/full-restricted/54-settings-theme-amoled.png index d4b233cf..707fb34f 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-restricted/54-settings-theme-amoled.png and b/tests/devices/kyocera-e4810/screenshots/full-restricted/54-settings-theme-amoled.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/41-settings-top.png b/tests/devices/kyocera-e4810/screenshots/full-touch/41-settings-top.png index cb1aa3a8..58c0efdd 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/41-settings-top.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/41-settings-top.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/42-settings-appearance.png b/tests/devices/kyocera-e4810/screenshots/full-touch/42-settings-appearance.png index e34245f2..e7f4db6a 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/42-settings-appearance.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/42-settings-appearance.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/43-settings-map.png b/tests/devices/kyocera-e4810/screenshots/full-touch/43-settings-map.png index 86a02427..73df4672 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/43-settings-map.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/43-settings-map.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/44-settings-place-pages.png b/tests/devices/kyocera-e4810/screenshots/full-touch/44-settings-place-pages.png index 5cabf511..fb3a74e2 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/44-settings-place-pages.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/44-settings-place-pages.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/45-settings-navigation.png b/tests/devices/kyocera-e4810/screenshots/full-touch/45-settings-navigation.png index 63c2aa1e..0718d25b 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/45-settings-navigation.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/45-settings-navigation.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/46-settings-voice.png b/tests/devices/kyocera-e4810/screenshots/full-touch/46-settings-voice.png index 43f3d42a..9151e770 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/46-settings-voice.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/46-settings-voice.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/47-settings-search.png b/tests/devices/kyocera-e4810/screenshots/full-touch/47-settings-search.png index a92d5e43..a82efaa8 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/47-settings-search.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/47-settings-search.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/48-settings-asr-engines.png b/tests/devices/kyocera-e4810/screenshots/full-touch/48-settings-asr-engines.png index a92d5e43..a82efaa8 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/48-settings-asr-engines.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/48-settings-asr-engines.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/49-settings-offline.png b/tests/devices/kyocera-e4810/screenshots/full-touch/49-settings-offline.png index d8ca1031..d8702e29 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/49-settings-offline.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/49-settings-offline.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/50-settings-saved-places.png b/tests/devices/kyocera-e4810/screenshots/full-touch/50-settings-saved-places.png index a8305ba1..e34d50ab 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/50-settings-saved-places.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/50-settings-saved-places.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/51-settings-privacy.png b/tests/devices/kyocera-e4810/screenshots/full-touch/51-settings-privacy.png index 6a5a9aea..746b95f0 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/51-settings-privacy.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/51-settings-privacy.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/52-settings-diagnostics.png b/tests/devices/kyocera-e4810/screenshots/full-touch/52-settings-diagnostics.png index c89eaaa9..3e8b3ebe 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/52-settings-diagnostics.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/52-settings-diagnostics.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/53-settings-about.png b/tests/devices/kyocera-e4810/screenshots/full-touch/53-settings-about.png index 5df86b2d..4ae6ef12 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/53-settings-about.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/53-settings-about.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full-touch/54-settings-theme-light.png b/tests/devices/kyocera-e4810/screenshots/full-touch/54-settings-theme-light.png index e5335b99..59b35cd0 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full-touch/54-settings-theme-light.png and b/tests/devices/kyocera-e4810/screenshots/full-touch/54-settings-theme-light.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/05-bare-map.png b/tests/devices/kyocera-e4810/screenshots/full/05-bare-map.png index 4d3144df..05d8755a 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/05-bare-map.png and b/tests/devices/kyocera-e4810/screenshots/full/05-bare-map.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/06-search-overlay.png b/tests/devices/kyocera-e4810/screenshots/full/06-search-overlay.png index 68c31b42..c2f89af1 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/06-search-overlay.png and b/tests/devices/kyocera-e4810/screenshots/full/06-search-overlay.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/07-search-results.png b/tests/devices/kyocera-e4810/screenshots/full/07-search-results.png index 46afab56..8464cd0e 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/07-search-results.png and b/tests/devices/kyocera-e4810/screenshots/full/07-search-results.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/08-search-history-astype.png b/tests/devices/kyocera-e4810/screenshots/full/08-search-history-astype.png index feb460fc..81e92a53 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/08-search-history-astype.png and b/tests/devices/kyocera-e4810/screenshots/full/08-search-history-astype.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/41-settings-top.png b/tests/devices/kyocera-e4810/screenshots/full/41-settings-top.png index bd651360..296acd28 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/41-settings-top.png and b/tests/devices/kyocera-e4810/screenshots/full/41-settings-top.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/42-settings-appearance.png b/tests/devices/kyocera-e4810/screenshots/full/42-settings-appearance.png index 999910f0..f1d02f6a 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/42-settings-appearance.png and b/tests/devices/kyocera-e4810/screenshots/full/42-settings-appearance.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/43-settings-map.png b/tests/devices/kyocera-e4810/screenshots/full/43-settings-map.png index 763dd4f0..74633044 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/43-settings-map.png and b/tests/devices/kyocera-e4810/screenshots/full/43-settings-map.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/44-settings-place-pages.png b/tests/devices/kyocera-e4810/screenshots/full/44-settings-place-pages.png index 846c1bc2..280ea6a4 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/44-settings-place-pages.png and b/tests/devices/kyocera-e4810/screenshots/full/44-settings-place-pages.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/45-settings-navigation.png b/tests/devices/kyocera-e4810/screenshots/full/45-settings-navigation.png index 849ff582..dd06a3c9 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/45-settings-navigation.png and b/tests/devices/kyocera-e4810/screenshots/full/45-settings-navigation.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/46-settings-voice.png b/tests/devices/kyocera-e4810/screenshots/full/46-settings-voice.png index a262d21f..b42c1382 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/46-settings-voice.png and b/tests/devices/kyocera-e4810/screenshots/full/46-settings-voice.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/47-settings-search.png b/tests/devices/kyocera-e4810/screenshots/full/47-settings-search.png index f02cb5f5..32e24ba6 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/47-settings-search.png and b/tests/devices/kyocera-e4810/screenshots/full/47-settings-search.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/48-settings-asr-engines.png b/tests/devices/kyocera-e4810/screenshots/full/48-settings-asr-engines.png index f02cb5f5..32e24ba6 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/48-settings-asr-engines.png and b/tests/devices/kyocera-e4810/screenshots/full/48-settings-asr-engines.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/49-settings-offline.png b/tests/devices/kyocera-e4810/screenshots/full/49-settings-offline.png index bab8eeff..2f54bf7e 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/49-settings-offline.png and b/tests/devices/kyocera-e4810/screenshots/full/49-settings-offline.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/50-settings-saved-places.png b/tests/devices/kyocera-e4810/screenshots/full/50-settings-saved-places.png index 4b2578ed..83bded9a 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/50-settings-saved-places.png and b/tests/devices/kyocera-e4810/screenshots/full/50-settings-saved-places.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/51-settings-privacy.png b/tests/devices/kyocera-e4810/screenshots/full/51-settings-privacy.png index 8900d209..c65e1fd0 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/51-settings-privacy.png and b/tests/devices/kyocera-e4810/screenshots/full/51-settings-privacy.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/52-settings-diagnostics.png b/tests/devices/kyocera-e4810/screenshots/full/52-settings-diagnostics.png index 0bf554f6..27aed0e2 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/52-settings-diagnostics.png and b/tests/devices/kyocera-e4810/screenshots/full/52-settings-diagnostics.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/53-settings-about.png b/tests/devices/kyocera-e4810/screenshots/full/53-settings-about.png index 53cb3d55..8387af5d 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/53-settings-about.png and b/tests/devices/kyocera-e4810/screenshots/full/53-settings-about.png differ diff --git a/tests/devices/kyocera-e4810/screenshots/full/54-settings-theme-light.png b/tests/devices/kyocera-e4810/screenshots/full/54-settings-theme-light.png index 12292b25..3d2b8137 100644 Binary files a/tests/devices/kyocera-e4810/screenshots/full/54-settings-theme-light.png and b/tests/devices/kyocera-e4810/screenshots/full/54-settings-theme-light.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/05-bare-map.png b/tests/devices/sonim-x320-225/screenshots/full/05-bare-map.png index 131b8ef7..cb77efae 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/05-bare-map.png and b/tests/devices/sonim-x320-225/screenshots/full/05-bare-map.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/06-search-overlay.png b/tests/devices/sonim-x320-225/screenshots/full/06-search-overlay.png index b7a250f8..1c602501 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/06-search-overlay.png and b/tests/devices/sonim-x320-225/screenshots/full/06-search-overlay.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/07-search-results.png b/tests/devices/sonim-x320-225/screenshots/full/07-search-results.png index e75d8d14..bcc00187 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/07-search-results.png and b/tests/devices/sonim-x320-225/screenshots/full/07-search-results.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/08-search-history-astype.png b/tests/devices/sonim-x320-225/screenshots/full/08-search-history-astype.png new file mode 100644 index 00000000..cfadc2de Binary files /dev/null and b/tests/devices/sonim-x320-225/screenshots/full/08-search-history-astype.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/41-settings-top.png b/tests/devices/sonim-x320-225/screenshots/full/41-settings-top.png index 0db178e8..c8b362e4 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/41-settings-top.png and b/tests/devices/sonim-x320-225/screenshots/full/41-settings-top.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/42-settings-appearance.png b/tests/devices/sonim-x320-225/screenshots/full/42-settings-appearance.png index 8cb1b266..e40fdd60 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/42-settings-appearance.png and b/tests/devices/sonim-x320-225/screenshots/full/42-settings-appearance.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/43-settings-map.png b/tests/devices/sonim-x320-225/screenshots/full/43-settings-map.png index 61c56974..2924d379 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/43-settings-map.png and b/tests/devices/sonim-x320-225/screenshots/full/43-settings-map.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/44-settings-place-pages.png b/tests/devices/sonim-x320-225/screenshots/full/44-settings-place-pages.png index 29db7b18..d225ec7f 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/44-settings-place-pages.png and b/tests/devices/sonim-x320-225/screenshots/full/44-settings-place-pages.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/45-settings-navigation.png b/tests/devices/sonim-x320-225/screenshots/full/45-settings-navigation.png index e47b770f..f8eadb41 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/45-settings-navigation.png and b/tests/devices/sonim-x320-225/screenshots/full/45-settings-navigation.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/46-settings-voice.png b/tests/devices/sonim-x320-225/screenshots/full/46-settings-voice.png index 30386554..c97fc8e2 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/46-settings-voice.png and b/tests/devices/sonim-x320-225/screenshots/full/46-settings-voice.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/47-settings-search.png b/tests/devices/sonim-x320-225/screenshots/full/47-settings-search.png index a768741b..4eb14bb5 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/47-settings-search.png and b/tests/devices/sonim-x320-225/screenshots/full/47-settings-search.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/48-settings-asr-engines.png b/tests/devices/sonim-x320-225/screenshots/full/48-settings-asr-engines.png index 2cf64278..4eb14bb5 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/48-settings-asr-engines.png and b/tests/devices/sonim-x320-225/screenshots/full/48-settings-asr-engines.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/49-settings-offline.png b/tests/devices/sonim-x320-225/screenshots/full/49-settings-offline.png index e08018f6..41b3d8a1 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/49-settings-offline.png and b/tests/devices/sonim-x320-225/screenshots/full/49-settings-offline.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/50-settings-saved-places.png b/tests/devices/sonim-x320-225/screenshots/full/50-settings-saved-places.png index 250e806b..b076abb5 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/50-settings-saved-places.png and b/tests/devices/sonim-x320-225/screenshots/full/50-settings-saved-places.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/51-settings-privacy.png b/tests/devices/sonim-x320-225/screenshots/full/51-settings-privacy.png index 17fa066d..7460f132 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/51-settings-privacy.png and b/tests/devices/sonim-x320-225/screenshots/full/51-settings-privacy.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/52-settings-diagnostics.png b/tests/devices/sonim-x320-225/screenshots/full/52-settings-diagnostics.png index d324e520..68e51444 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/52-settings-diagnostics.png and b/tests/devices/sonim-x320-225/screenshots/full/52-settings-diagnostics.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/53-settings-about.png b/tests/devices/sonim-x320-225/screenshots/full/53-settings-about.png index 5875e292..26ef7877 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/53-settings-about.png and b/tests/devices/sonim-x320-225/screenshots/full/53-settings-about.png differ diff --git a/tests/devices/sonim-x320-225/screenshots/full/54-settings-theme-light.png b/tests/devices/sonim-x320-225/screenshots/full/54-settings-theme-light.png index 6299419c..2b69cf29 100644 Binary files a/tests/devices/sonim-x320-225/screenshots/full/54-settings-theme-light.png and b/tests/devices/sonim-x320-225/screenshots/full/54-settings-theme-light.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/05-bare-map.png b/tests/devices/sonim-x320/screenshots/full-restricted/05-bare-map.png index 38d1c129..12783c71 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/05-bare-map.png and b/tests/devices/sonim-x320/screenshots/full-restricted/05-bare-map.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/06-search-overlay.png b/tests/devices/sonim-x320/screenshots/full-restricted/06-search-overlay.png index 2d41caa0..c015ad0a 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/06-search-overlay.png and b/tests/devices/sonim-x320/screenshots/full-restricted/06-search-overlay.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/07-search-results.png b/tests/devices/sonim-x320/screenshots/full-restricted/07-search-results.png index 24682591..af0f32da 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/07-search-results.png and b/tests/devices/sonim-x320/screenshots/full-restricted/07-search-results.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/08-search-history-astype.png b/tests/devices/sonim-x320/screenshots/full-restricted/08-search-history-astype.png new file mode 100644 index 00000000..18b5ad5a Binary files /dev/null and b/tests/devices/sonim-x320/screenshots/full-restricted/08-search-history-astype.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/41-settings-top.png b/tests/devices/sonim-x320/screenshots/full-restricted/41-settings-top.png index 9d0fa607..991ac980 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/41-settings-top.png and b/tests/devices/sonim-x320/screenshots/full-restricted/41-settings-top.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/42-settings-appearance.png b/tests/devices/sonim-x320/screenshots/full-restricted/42-settings-appearance.png index efa2bb46..0050f344 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/42-settings-appearance.png and b/tests/devices/sonim-x320/screenshots/full-restricted/42-settings-appearance.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/43-settings-map.png b/tests/devices/sonim-x320/screenshots/full-restricted/43-settings-map.png index 5919110e..f63e865d 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/43-settings-map.png and b/tests/devices/sonim-x320/screenshots/full-restricted/43-settings-map.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/44-settings-navigation.png b/tests/devices/sonim-x320/screenshots/full-restricted/44-settings-navigation.png index bf356888..9fcada79 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/44-settings-navigation.png and b/tests/devices/sonim-x320/screenshots/full-restricted/44-settings-navigation.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/45-settings-voice.png b/tests/devices/sonim-x320/screenshots/full-restricted/45-settings-voice.png index 1c0ff6b9..a9caa987 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/45-settings-voice.png and b/tests/devices/sonim-x320/screenshots/full-restricted/45-settings-voice.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/46-settings-search.png b/tests/devices/sonim-x320/screenshots/full-restricted/46-settings-search.png index ac6b2b27..6b61dc91 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/46-settings-search.png and b/tests/devices/sonim-x320/screenshots/full-restricted/46-settings-search.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/47-settings-asr-engines.png b/tests/devices/sonim-x320/screenshots/full-restricted/47-settings-asr-engines.png index 87aadd68..6b61dc91 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/47-settings-asr-engines.png and b/tests/devices/sonim-x320/screenshots/full-restricted/47-settings-asr-engines.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/48-settings-offline.png b/tests/devices/sonim-x320/screenshots/full-restricted/48-settings-offline.png index 939c12a9..b1c658f0 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/48-settings-offline.png and b/tests/devices/sonim-x320/screenshots/full-restricted/48-settings-offline.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/49-settings-saved-places.png b/tests/devices/sonim-x320/screenshots/full-restricted/49-settings-saved-places.png index b9adbd9f..749899f8 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/49-settings-saved-places.png and b/tests/devices/sonim-x320/screenshots/full-restricted/49-settings-saved-places.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/50-settings-privacy.png b/tests/devices/sonim-x320/screenshots/full-restricted/50-settings-privacy.png index 5837e43c..89ad2b84 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/50-settings-privacy.png and b/tests/devices/sonim-x320/screenshots/full-restricted/50-settings-privacy.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/51-settings-diagnostics.png b/tests/devices/sonim-x320/screenshots/full-restricted/51-settings-diagnostics.png index 2a963303..2d228106 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/51-settings-diagnostics.png and b/tests/devices/sonim-x320/screenshots/full-restricted/51-settings-diagnostics.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/52-settings-about.png b/tests/devices/sonim-x320/screenshots/full-restricted/52-settings-about.png index 94ea101a..303fb3f5 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/52-settings-about.png and b/tests/devices/sonim-x320/screenshots/full-restricted/52-settings-about.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/53-settings-theme-light.png b/tests/devices/sonim-x320/screenshots/full-restricted/53-settings-theme-light.png index 1746e700..f37f743a 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/53-settings-theme-light.png and b/tests/devices/sonim-x320/screenshots/full-restricted/53-settings-theme-light.png differ diff --git a/tests/devices/sonim-x320/screenshots/full-restricted/54-settings-theme-amoled.png b/tests/devices/sonim-x320/screenshots/full-restricted/54-settings-theme-amoled.png index 75dc2387..689b864a 100644 Binary files a/tests/devices/sonim-x320/screenshots/full-restricted/54-settings-theme-amoled.png and b/tests/devices/sonim-x320/screenshots/full-restricted/54-settings-theme-amoled.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/05-bare-map.png b/tests/devices/sonim-x320/screenshots/full/05-bare-map.png index 9d284a32..33c5a03c 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/05-bare-map.png and b/tests/devices/sonim-x320/screenshots/full/05-bare-map.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/06-search-overlay.png b/tests/devices/sonim-x320/screenshots/full/06-search-overlay.png index 16fe0289..f8a50c47 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/06-search-overlay.png and b/tests/devices/sonim-x320/screenshots/full/06-search-overlay.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/07-search-results.png b/tests/devices/sonim-x320/screenshots/full/07-search-results.png index 338b5583..cf851bf4 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/07-search-results.png and b/tests/devices/sonim-x320/screenshots/full/07-search-results.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/08-search-history-astype.png b/tests/devices/sonim-x320/screenshots/full/08-search-history-astype.png index 59ede079..2d20d57f 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/08-search-history-astype.png and b/tests/devices/sonim-x320/screenshots/full/08-search-history-astype.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/41-settings-top.png b/tests/devices/sonim-x320/screenshots/full/41-settings-top.png index 094476e4..2d198cb6 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/41-settings-top.png and b/tests/devices/sonim-x320/screenshots/full/41-settings-top.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/42-settings-appearance.png b/tests/devices/sonim-x320/screenshots/full/42-settings-appearance.png index 1094486d..fb00721a 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/42-settings-appearance.png and b/tests/devices/sonim-x320/screenshots/full/42-settings-appearance.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/43-settings-map.png b/tests/devices/sonim-x320/screenshots/full/43-settings-map.png index 5ec2092b..511c0d06 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/43-settings-map.png and b/tests/devices/sonim-x320/screenshots/full/43-settings-map.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/44-settings-place-pages.png b/tests/devices/sonim-x320/screenshots/full/44-settings-place-pages.png index b0de8532..dafc9725 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/44-settings-place-pages.png and b/tests/devices/sonim-x320/screenshots/full/44-settings-place-pages.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/45-settings-navigation.png b/tests/devices/sonim-x320/screenshots/full/45-settings-navigation.png index ecfc7102..38b29ad1 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/45-settings-navigation.png and b/tests/devices/sonim-x320/screenshots/full/45-settings-navigation.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/46-settings-voice.png b/tests/devices/sonim-x320/screenshots/full/46-settings-voice.png index b9189cc0..7989852b 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/46-settings-voice.png and b/tests/devices/sonim-x320/screenshots/full/46-settings-voice.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/47-settings-search.png b/tests/devices/sonim-x320/screenshots/full/47-settings-search.png index dca0a6a0..75a9e439 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/47-settings-search.png and b/tests/devices/sonim-x320/screenshots/full/47-settings-search.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/48-settings-asr-engines.png b/tests/devices/sonim-x320/screenshots/full/48-settings-asr-engines.png index dca0a6a0..75a9e439 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/48-settings-asr-engines.png and b/tests/devices/sonim-x320/screenshots/full/48-settings-asr-engines.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/49-settings-offline.png b/tests/devices/sonim-x320/screenshots/full/49-settings-offline.png index c0996d6b..a0be36a0 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/49-settings-offline.png and b/tests/devices/sonim-x320/screenshots/full/49-settings-offline.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/50-settings-saved-places.png b/tests/devices/sonim-x320/screenshots/full/50-settings-saved-places.png index 3f495ee7..5d1ba4d9 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/50-settings-saved-places.png and b/tests/devices/sonim-x320/screenshots/full/50-settings-saved-places.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/51-settings-privacy.png b/tests/devices/sonim-x320/screenshots/full/51-settings-privacy.png index 27f1ec28..d8b50c5b 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/51-settings-privacy.png and b/tests/devices/sonim-x320/screenshots/full/51-settings-privacy.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/52-settings-diagnostics.png b/tests/devices/sonim-x320/screenshots/full/52-settings-diagnostics.png index 08db37e0..d43b5122 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/52-settings-diagnostics.png and b/tests/devices/sonim-x320/screenshots/full/52-settings-diagnostics.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/53-settings-about.png b/tests/devices/sonim-x320/screenshots/full/53-settings-about.png index 65be27e6..efe293b6 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/53-settings-about.png and b/tests/devices/sonim-x320/screenshots/full/53-settings-about.png differ diff --git a/tests/devices/sonim-x320/screenshots/full/54-settings-theme-light.png b/tests/devices/sonim-x320/screenshots/full/54-settings-theme-light.png index 7798161c..4d02eefa 100644 Binary files a/tests/devices/sonim-x320/screenshots/full/54-settings-theme-light.png and b/tests/devices/sonim-x320/screenshots/full/54-settings-theme-light.png differ diff --git a/tests/dpad/lib.sh b/tests/dpad/lib.sh index 42a9a049..3402c9fd 100755 --- a/tests/dpad/lib.sh +++ b/tests/dpad/lib.sh @@ -134,6 +134,45 @@ for m in re.finditer(r"]*>", d): } # on_screen - 0 (true) if a node with that exact text exists. on_screen() { [ -n "$(find_text "$1")" ]; } +# find_text_ci / on_screen_ci - case-INSENSITIVE exact match. For text whose casing depends on which +# UI path produced it: the "Coffee" category chip records its stable English query literal +# ("Coffee"), the typed fallback records what was typed ("coffee") - a case-sensitive check on +# either spelling systematically misses the other path (device-found: search-history-astype missed +# on every leg, on main and branch alike, because the chip path had recorded "Coffee"). +find_text_ci() { + ui_dump + $ADB shell cat /sdcard/ui.xml 2>/dev/null | python3 -c ' +import sys, re, html +d = sys.stdin.read(); want = sys.argv[1].casefold() +for m in re.finditer(r"]*>", d): + s = m.group(0); t = re.search(r"text=\"([^\"]*)\"", s); b = re.search(r"bounds=\"([^\"]*)\"", s) + if t and html.unescape(t.group(1)).casefold() == want: + print(b.group(1) if b else ""); break +' "$1" +} +on_screen_ci() { [ -n "$(find_text_ci "$1")" ]; } +# on_screen_within [seconds=6] - poll on_screen until it matches or the deadline passes. +# ui_dump's thin-tree retry can't catch a STRUCTURALLY-real dump whose text attributes are still +# empty while Compose populates semantics - device-seen on the search overlay (a dozen nodes, every +# text="", settled ~1 s later with the same node count). Checks that race an animating surface +# (IME up, suggestions streaming in) poll here instead of trusting one post-sleep dump. +on_screen_within() { + local deadline=$(( $(date +%s) + ${2:-6} )) + while :; do + on_screen "$1" && return 0 + [ "$(date +%s)" -ge "$deadline" ] && return 1 + sleep 0.7 + done +} +# on_screen_ci_within [seconds=6] - the case-insensitive twin. +on_screen_ci_within() { + local deadline=$(( $(date +%s) + ${2:-6} )) + while :; do + on_screen_ci "$1" && return 0 + [ "$(date +%s)" -ge "$deadline" ] && return 1 + sleep 0.7 + done +} # find_text_contains - bounds of the first node whose text CONTAINS . find_text_contains() { ui_dump diff --git a/tools/translations.lock.json b/tools/translations.lock.json index 09be286d..d709948f 100644 --- a/tools/translations.lock.json +++ b/tools/translations.lock.json @@ -1,4 +1,346 @@ { + "car_camera_ahead": { + "en": "ba23121089ebbf2b", + "loc": { + "de": "8a722bee14fbf95a", + "es": "d933f4063d485d40", + "fr": "315f9f4747a6e5da", + "it": "0817fb8a60feba89", + "iw": "4bab09407fe9eeae", + "ja": "58a983b9582169d6", + "nl": "6722e8b941e1b6f0", + "pl": "df98a6dc905a27e2", + "pt": "b05b6c3ee5c8b0c9", + "ru": "eb3e9231661f0f9e", + "sv": "a31e60642f4fe87a", + "uk": "07d80a4cd345fb0f", + "zh": "19a437a674a6d636", + "zh-rTW": "9c16cfb6c727fe69" + } + }, + "car_categories": { + "en": "b8b1d894c6837252", + "loc": { + "de": "055b8f9b9e5064b3", + "es": "7ef4eab73ebc9b95", + "fr": "4b151208bce659bb", + "it": "3977d35296ee808f", + "iw": "adff1299c11f9875", + "ja": "88074d4c5cc66e8f", + "nl": "37eafcd79015e359", + "pl": "52f2a87a419c0666", + "pt": "cba7e08a07787029", + "ru": "05f6c615a3518d94", + "sv": "98d7967914416f2b", + "uk": "82a94fb896dd5e12", + "zh": "0d12cbd6562bfbff", + "zh-rTW": "b4d250d23bedfd0e" + } + }, + "car_details": { + "en": "45989de49fb7f66d", + "loc": { + "de": "45989de49fb7f66d", + "es": "c8a74ca68d39907d", + "fr": "17eaee489b095c37", + "it": "1a45b008319a26a1", + "iw": "63edb3705e209cd4", + "ja": "9652e7b5f69922bd", + "nl": "45989de49fb7f66d", + "pl": "02a49e904b960f3b", + "pt": "62c95076dbeb3160", + "ru": "0fefbd67d8332867", + "sv": "b59da76d61fac33a", + "uk": "2a0b62e6db65be19", + "zh": "979a332955c855bf", + "zh-rTW": "1791bb652092a436" + } + }, + "car_end": { + "en": "f4db1e48476f6075", + "loc": { + "de": "60935a92d7da7cd1", + "es": "80a5037a33b80200", + "fr": "b4d92528f877cff1", + "it": "f5c0a84d0b930a14", + "iw": "b9d0a0b2ad02e194", + "ja": "8f26d438109c8774", + "nl": "a8f541b215e5f756", + "pl": "6d84caae90cc4498", + "pt": "cfcc6ad6edac1325", + "ru": "8be2bbf39a833998", + "sv": "2433001fee6378d4", + "uk": "6d3ba2315111a694", + "zh": "c7b24e7997e96eaa", + "zh-rTW": "ee1f5ad7d73a5229" + } + }, + "car_faster_route": { + "en": "2c60ab2009df78f8", + "loc": { + "de": "d8282dd0590e62e6", + "es": "1706b02e2a7a75d2", + "fr": "bb86245372bacc15", + "it": "be4c03f64587f6cf", + "iw": "5068510334b323e1", + "ja": "2a365e5b73ab6fd8", + "nl": "16405644fb09e69a", + "pl": "593a712a32d08dc4", + "pt": "704bc9ca8a1232cd", + "ru": "cbd482d7a8e54c16", + "sv": "19e09127c1ebdd93", + "uk": "9e9b4af75884f79d", + "zh": "abe3c3071b7406b3", + "zh-rTW": "9611b1a619c30ae8" + } + }, + "car_go": { + "en": "6cc8519b91584e8b", + "loc": { + "de": "3d6a124622df7afc", + "es": "460edadffdaf99de", + "fr": "9e3df88a709a559a", + "it": "d0cfbdc71dab5d4e", + "iw": "fe201a2d68d826a9", + "ja": "cc147e162c82bbc7", + "nl": "ab8f46084b4fa0fc", + "pl": "fb91e6ec97df6803", + "pt": "460edadffdaf99de", + "ru": "520cf00154530374", + "sv": "81524b9b99c8cd98", + "uk": "48d92b289d5fdc14", + "zh": "efdc22fca1c6310f", + "zh-rTW": "6bc6d98c34e7cc69" + } + }, + "car_mute": { + "en": "8dd6857baf026850", + "loc": { + "de": "d9b25d566f67fb13", + "es": "8953cc8c322878ab", + "fr": "22251c6e8e32b2e6", + "it": "99a87278b3f6a762", + "iw": "053042b198276c54", + "ja": "c7fe7a921e360f4d", + "nl": "e10488892aeee606", + "pl": "ea3c1ef5b57b7970", + "pt": "8953cc8c322878ab", + "ru": "b50aa55c0b78cd68", + "sv": "f3002b72ca683a02", + "uk": "252b15188faa4376", + "zh": "7db2e189ecb01065", + "zh-rTW": "b4113bb5c7cd095d" + } + }, + "car_no_destinations": { + "en": "00ff46188896bc98", + "loc": { + "de": "f77f751ce44cea2c", + "es": "986e78c5dfaca672", + "fr": "4b6683016c13be5e", + "it": "191e0ddc64c57427", + "iw": "2c3472b408facdba", + "ja": "73545476b3dd61ac", + "nl": "f9659f84d96f1324", + "pl": "78f4f16f640270fc", + "pt": "4b8e21f685aa6615", + "ru": "2e92f6778e773d7a", + "sv": "a5488436c288aa3d", + "uk": "6ca9f6f1646145b0", + "zh": "c329d8c3f33fa2eb", + "zh-rTW": "76c4e3aaaa3788ca" + } + }, + "car_no_route": { + "en": "b219126c7c797747", + "loc": { + "de": "16326ca81fd83181", + "es": "a43c3153c18bc82d", + "fr": "666a8dcd6b11d517", + "it": "0e0f6bc3d08954d9", + "iw": "5ff5f582f9e66a21", + "ja": "b8ab4551566d048e", + "nl": "4ba8ce3384277a90", + "pl": "9cdf5ae7417f8202", + "pt": "8d0b1c141d3b00f7", + "ru": "bda7a0c87e2cd638", + "sv": "91669b21f53f563f", + "uk": "bf1ff52ae42edb67", + "zh": "75c7552c11433c7f", + "zh-rTW": "66b31df856c4db0c" + } + }, + "car_no_stops": { + "en": "ec3f7d9791e9f557", + "loc": { + "de": "892d1cd89b78dfb8", + "es": "65e9d25657ddff03", + "fr": "b9c8745150a74e3d", + "it": "d3ca253c59d99362", + "iw": "b6085d23136e7766", + "ja": "47ffa7e52ba9e622", + "nl": "041c0f152bbb356b", + "pl": "1e72b1bb8438aad6", + "pt": "f7c34baea860a1a5", + "ru": "16d0b969d0183efc", + "sv": "b9daf7e8e34b7296", + "uk": "6e0775189cfc174f", + "zh": "ebb1db9616db29b1", + "zh-rTW": "7361e0d72b1fa556" + } + }, + "car_search": { + "en": "49c266baaaa70981", + "loc": { + "de": "dd097e77dd737a2a", + "es": "5f55edf900896012", + "fr": "733d1c742675f537", + "it": "15910e265a5d3cfc", + "iw": "cd547ee1df4d4b07", + "ja": "1b6b1a6f89315f6e", + "nl": "5d33a9ac30b91d99", + "pl": "dcef96697b8a7e9d", + "pt": "b893109d2a2c4d16", + "ru": "180f58ab975323f9", + "sv": "335237e7c9b908e7", + "uk": "c2c43e5dd7dd4d16", + "zh": "44ce7ae909bbb28b", + "zh-rTW": "03c481a6ab85c30d" + } + }, + "car_search_hint": { + "en": "d9895d90d0909e2d", + "loc": { + "de": "b6c0ff3988b19d95", + "es": "0fa461fb898bbb85", + "fr": "e786a632e7ef1ce3", + "it": "4d8b20fa1d518580", + "iw": "984bf108bd40ec71", + "ja": "16b7d61d19130a92", + "nl": "290d6aa205f9b8d5", + "pl": "c522dfae3abc67a5", + "pt": "2a76574cbcf787ef", + "ru": "4bebaa9dccb50ff9", + "sv": "4841b4bfed08d94a", + "uk": "70e0ab72267dcf19", + "zh": "fde4f8289c9fe18b", + "zh-rTW": "f2e5d09c8a13e2d8" + } + }, + "car_stop_added": { + "en": "45beb81561b41b87", + "loc": { + "de": "198c99e51c039587", + "es": "2b9c60e0d7b576b4", + "fr": "520da32bae1d3296", + "it": "c361235328416e79", + "iw": "a65c21a4a3327299", + "ja": "08e5fa7c1c139bba", + "nl": "e8fd509762f49491", + "pl": "42711d5a02a8c1e0", + "pt": "083de4fa259425e6", + "ru": "af972cdab9af2deb", + "sv": "dd3fed3445a5876e", + "uk": "dc7bc5c37d026c51", + "zh": "4c085c214220d5aa", + "zh-rTW": "c62124e79c895524" + } + }, + "car_stop_remove_hint": { + "en": "2b305f16827c9e02", + "loc": { + "de": "b1f3ccea743e3baf", + "es": "ccda5be698c8a5d1", + "fr": "8e857a5d58229c5c", + "it": "e190d7ec64923235", + "iw": "f6ad0512bf586e6e", + "ja": "ce8ffc7e8840ff2c", + "nl": "da274ddccfffef3b", + "pl": "e02ad1459bd02b45", + "pt": "dbc639ee485327c7", + "ru": "e9f8bf81a776400d", + "sv": "b312fba7042161bf", + "uk": "8cfa6388e0b182e2", + "zh": "885117bc136b1a3f", + "zh-rTW": "578ae4222d74c2af" + } + }, + "car_stop_removed": { + "en": "08411fa93ae8196e", + "loc": { + "de": "ceb7d0d4039d2acb", + "es": "7a80824cff76aae1", + "fr": "679af516d12d1216", + "it": "fc9c1f47791d65f5", + "iw": "862671950e92ef0d", + "ja": "b11b4b2fe320e8c4", + "nl": "8dce1092b39e91dd", + "pl": "e43730a7f1440c81", + "pt": "351efd64de1159ab", + "ru": "87930c9419ad29e2", + "sv": "4eadeb5dcd272801", + "uk": "f497077e3d026165", + "zh": "9dad2f675bf62e78", + "zh-rTW": "e7cd8ded11543797" + } + }, + "car_stops": { + "en": "877a8887947ed45b", + "loc": { + "de": "c0766288f23694bd", + "es": "d3de30eb56fe1739", + "fr": "86696d6754115258", + "it": "bcc56705eec37ee7", + "iw": "ad7968fbfad1fdca", + "ja": "925bfd6a30cce32f", + "nl": "877a8887947ed45b", + "pl": "362d53ff1a52c905", + "pt": "d3de30eb56fe1739", + "ru": "04677c7462d13428", + "sv": "268829685bf7f35f", + "uk": "c3fb83d5e371b4b5", + "zh": "271098558885f0ef", + "zh-rTW": "fa1e55092041e113" + } + }, + "car_unmute": { + "en": "ce4ee4efc5e324fc", + "loc": { + "de": "2be1036593883eda", + "es": "be6ba969a39d7f61", + "fr": "0efc2e864fe1820d", + "it": "8bad111f45f71aab", + "iw": "5f849bca67f444f2", + "ja": "a0a45e78c5f15892", + "nl": "9019ea90b63cf2fa", + "pl": "d924679848f7647a", + "pt": "7e3d4925b5c5b5e4", + "ru": "aa1a4d976ea37aaf", + "sv": "a8055d70d17973c1", + "uk": "b84a95dbe44de895", + "zh": "8d01732e7477851b", + "zh-rTW": "2cca75857ea72026" + } + }, + "car_voice_nothing": { + "en": "0da533083a4fb069", + "loc": { + "de": "5c4afd324451d79f", + "es": "3168f50f376bfe40", + "fr": "a28dd4aee4cb7b1f", + "it": "f243a68756cacfee", + "iw": "6ba2e2cd4263740f", + "ja": "3f91fb232d032aa7", + "nl": "154b3d28d772d6de", + "pl": "3cc9f5f959fcf156", + "pt": "78985a2216039faa", + "ru": "042ba06a448299f8", + "sv": "ab59e7213d1dc886", + "uk": "c8eb204163ab3bfd", + "zh": "97a17a020c2a8cb1", + "zh-rTW": "a552823206139493" + } + }, "cat_atms": { "en": "ad1841c2825df920", "loc": { @@ -18,6 +360,25 @@ "zh-rTW": "ffc027edcc0ef3f2" } }, + "cat_charging": { + "en": "c357dfbf4c433c06", + "loc": { + "de": "d1217d3262ea1bf4", + "es": "2dbf26f5f3c89412", + "fr": "5b451a4ad842a238", + "it": "149905895ca71d4a", + "iw": "ee899b8bfae72072", + "ja": "0bb8a96de7b2e930", + "nl": "699df9bd023c9ad0", + "pl": "e7baa66f2e97b91b", + "pt": "761a05dd22b7981e", + "ru": "ca563931c2e3592a", + "sv": "1c7d85b093820a86", + "uk": "7c478932122ee786", + "zh": "c9fa3ce0908f8083", + "zh-rTW": "8c522c67a83c0ac0" + } + }, "cat_coffee": { "en": "b0e1f06cf1e7d466", "loc": { @@ -113,6 +474,25 @@ "zh-rTW": "5050a20a0f23563c" } }, + "cat_parking": { + "en": "4a64d6c849df0110", + "loc": { + "de": "25408090fcf54ce5", + "es": "7dba4b6299cd4b89", + "fr": "4a64d6c849df0110", + "it": "e7b0154e8b88bd53", + "iw": "a86508ab54583db9", + "ja": "798fa88035fbd098", + "nl": "9d40a4469517f2d2", + "pl": "4a64d6c849df0110", + "pt": "a144d34915183505", + "ru": "d34ff2a3e8fe5aea", + "sv": "d894f230b4858129", + "uk": "d34ff2a3e8fe5aea", + "zh": "22964380dd7f66a7", + "zh-rTW": "6bb236624e987a76" + } + }, "cat_parks": { "en": "11d467de82c5e1fc", "loc": { @@ -721,23 +1101,23 @@ "zh-rTW": "1f41273c6e1e7ab7" } }, - "mapscreen_clear_recent_searches": { - "en": "a32329b790344c69", + "mapscreen_clear_recents": { + "en": "d96f3bbd067f8248", "loc": { - "de": "8e9844abe6c06297", - "es": "a3648c4b0c599d12", - "fr": "59bdc343312f6f7e", - "it": "0add199aafe4728f", - "iw": "1058d578742a8051", - "ja": "a67f2406bf86784e", - "nl": "ae9899d644e2031a", - "pl": "35f5220a8185776d", - "pt": "27447ed4c8736944", - "ru": "2b9fbf3d2cbb6d73", - "sv": "0f5d2a9c5136e7e6", - "uk": "8f028ff7c4e5c149", - "zh": "8353eea4a29b6357", - "zh-rTW": "6219b91df6521053" + "de": "7210b4af2dfc2ac1", + "es": "7a4cfecac29c6e82", + "fr": "64337f5890b4e6ae", + "it": "e355053188a63fba", + "iw": "f08cf76ecd9ff1ca", + "ja": "86815601500bec8e", + "nl": "a4b18028a6c0bddd", + "pl": "351b43e7901012b0", + "pt": "a969c2cbcc674018", + "ru": "2c43d212bd098966", + "sv": "2f737554a67f9f40", + "uk": "c7924455361d96b8", + "zh": "37ec1038f778b16c", + "zh-rTW": "ace91cb1bf761798" } }, "mapscreen_close_results": { @@ -1329,25 +1709,6 @@ "zh-rTW": "997a5e6e513f2a90" } }, - "mapscreen_section_recent_searches": { - "en": "228c84b5a56e6bce", - "loc": { - "de": "ebfabb5038d823d8", - "es": "a78e69b3140bc0cc", - "fr": "dcaa4cc25ba265a6", - "it": "12fa9e5f2daef085", - "iw": "cb67e6ced995023d", - "ja": "3cc1515fb532dd03", - "nl": "1ce1c3e805aa4767", - "pl": "204ffdf898bc512a", - "pt": "01b5c9a67bea7309", - "ru": "5d046399a6cde97c", - "sv": "11bdf6eaa012dbe2", - "uk": "c9204f2d10c34c85", - "zh": "3b5ac9baaaae2f12", - "zh-rTW": "37d3bd6e75200a83" - } - }, "mapscreen_section_saved": { "en": "b5c120b316c237a0", "loc": { @@ -5452,6 +5813,82 @@ "zh-rTW": "aefdb61bf1f3aba8" } }, + "settings_alert_cameras": { + "en": "707010df876b5f09", + "loc": { + "de": "f679e8cd22c6cca5", + "es": "e1b37b8040839538", + "fr": "6d988c2e10dfdbac", + "it": "cbc482b094ca48ed", + "iw": "1d8957f8f2c6169f", + "ja": "988f2533c2fc2c31", + "nl": "b3bf773ad99bd236", + "pl": "199524061a4ce925", + "pt": "d9370f9b988ce3c9", + "ru": "3faaae968f747e82", + "sv": "fcd5fdc48e1d60f1", + "uk": "1ad9de62e40d72c3", + "zh": "b1a28c9f75dfac48", + "zh-rTW": "017ab9fa9da0d9f2" + } + }, + "settings_alert_cameras_hint": { + "en": "06308c5797c48d2c", + "loc": { + "de": "c7c4ca11354ad5c7", + "es": "81c28a832518ce31", + "fr": "6c79c04e86c68be3", + "it": "95e314583e4f1094", + "iw": "614e661b978a1763", + "ja": "426a573f3b2f3f67", + "nl": "cb3103c4a14673d0", + "pl": "8661ec13ca8407a5", + "pt": "14524955a18658f6", + "ru": "7135fb38485a0e42", + "sv": "da394ebbd8570a85", + "uk": "b53aeec11cf85ef1", + "zh": "ce844dd2033831dc", + "zh-rTW": "831962e8a634572b" + } + }, + "settings_alert_speeding": { + "en": "1f00cd59b26836ba", + "loc": { + "de": "737b064ce97097cf", + "es": "0757840eaff71964", + "fr": "ff633c3322b71b50", + "it": "679b6515ec466b5a", + "iw": "834d5cd1616e2b45", + "ja": "4b3abc77369097f3", + "nl": "c486fb0c7b278d7a", + "pl": "4bdc0aa1a238c057", + "pt": "e01bfb0a1beb2f78", + "ru": "aea6df706c5faf57", + "sv": "ac190835fb48f918", + "uk": "cb9ecbdd446cd33e", + "zh": "e3b576c8329e51be", + "zh-rTW": "e3b576c8329e51be" + } + }, + "settings_alert_speeding_hint": { + "en": "f6aaedd99496080e", + "loc": { + "de": "1e182461ac5e22fd", + "es": "2ebe8a88468de130", + "fr": "198333ee24623f62", + "it": "b1ab5875ecfaaded", + "iw": "26238e9fe797a40f", + "ja": "403b90ffb756a947", + "nl": "8cfc2ad7cbeb1da8", + "pl": "476bd4d5137ec911", + "pt": "7e5183f5f7ec5af3", + "ru": "46c3c6146982b9ad", + "sv": "e5e77a71e2bdb2d9", + "uk": "6489b69928a6a6cc", + "zh": "f1c92b8672c45e02", + "zh-rTW": "574c9e49316c5f17" + } + }, "settings_appearance": { "en": "3907fa7f80722a6f", "loc": { @@ -6003,6 +6440,25 @@ "zh-rTW": "d477c75aa6561c9f" } }, + "settings_drive_alerts": { + "en": "4771e5a97ac57e27", + "loc": { + "de": "c2b76b3ded20d8b4", + "es": "b434a3faf1259094", + "fr": "88e60146c66547c7", + "it": "63d6810f13d8ba10", + "iw": "79812014d8a1bb61", + "ja": "d64be73866c4da5f", + "nl": "1ce062b90be300c8", + "pl": "d306076cd88ad194", + "pt": "db715ec163f29e17", + "ru": "1621118e688650a2", + "sv": "1d6bc23f97bf4342", + "uk": "80328bae4e6cc4b1", + "zh": "3fcc3a88e6e73448", + "zh-rTW": "2285ad3abe744c8a" + } + }, "settings_expand": { "en": "07548c2c6511d4d1", "loc": {