# Vela Maps - Specification > The single authoritative description of **what Vela is, how it's built, and every > load-bearing decision** - the target to rebuild against if the codebase is ever > lost or reimplemented. Paired with [`FEATURES.md`](FEATURES.md) (the exhaustive > feature catalogue), [`README.md`](README.md) (the public overview; the calibration > walk-through) and [`CLAUDE.md`](CLAUDE.md) (build rules + gotchas). When behaviour, > calibration, or architecture changes, **update this file in the same commit.** Last reviewed: 2026-07-13. --- ## 1. What it is Vela Maps (`app.vela`) is a **degoogled, keyless Google-Maps replacement for Android** - "the NewPipe for Maps." It gives Google-parity search, places, routing, traffic-aware ETAs and turn-by-turn navigation on a phone with **no Google Play Services** (GrapheneOS / no-GMS ROMs), distributed via F-Droid/Obtainium, GPLv3. ### Ethos / non-negotiables - **No Vela backend.** Every install talks to Google directly from the user's own IP, behaving like one logged-out browser. No shared API key, no server farm. - **No static shared Google API key, ever.** Per-user `GoogleSession` bootstrap only (this is the NewPipe legal footing). - **Open basemap, scraped intelligence.** The map tiles are open vector tiles (keyless OpenFreeMap), recoloured at runtime. Only POIs/routing/traffic are scraped from Google's *public web* endpoints - the same ones `maps.google.com` calls. - **Degoogled at runtime** (hard rules, §6): AOSP location/TTS only, no FCM/Firebase/ Fused/Play Integrity. - **Maintenance is a lifestyle.** Google rotates these endpoints; the extractor is built to be **re-calibrated and even hot-patched without an app update** (§5). ### Non-goals - Account sync. (In-app Street View, turn-by-turn *offline* routing and photo author/date, once non-goals, are all DONE - the pano viewer renders Google's equirect tiles on its own GL sphere since 2026-07-15 (embedding Google's WebGL page rendered black, which is what originally parked it), GraphHopper covers offline routing, the reviews DOM scrape covers photo author/date.) See `FEATURES.md` "Known debts." - **Popular/busy times - DONE keyless (2026-06-19), not a non-goal.** Earlier I wrongly ruled it sign-in-gated: the histogram (`[84]`) is stripped from the keyless **OkHttp** search (bot-degraded, like photos/transit), but a **warmed hidden WebView's same-origin search returns the full response with `[84]`** (`WebPopularTimesFetcher` + `Popular- TimesParser`, same trick as photos). **Load-bearing nuance:** the WebView query must be **specific (name + address)** - a bare-name search still comes back as a 20-result `[64]` list trimmed of `[84]`, whereas name+address resolves to the single focused result (`[0][1][0][14]`) that keeps it. Lesson: a "needs login" call from the OkHttp response alone should be re-checked through a real WebView engine. --- ## 2. Architecture Two Gradle modules, strict boundary: - **`:core`** - the UI-agnostic *extractor* (NewPipeExtractor pattern). Models, the `MapDataSource` seam, the Google scraper, parsers, pb builders, polyline codec, the pure nav engine, location/voice/haptics abstractions, the remote-config layer, and the opt-in **diagnostics** ring (`core/diag/DiagLog` - off by default; the scraper + nav record breadcrumbs only when enabled; `app/diag/DiagExporter` shares them as a JSON bundle, user-initiated, never auto-uploaded - the no-backend half of the telemetry plan). **No MapLibre or Android-UI types may leak in** (convert `LatLng` at the view edge). - **`:app`** - the Compose UI + MapLibre Native 11.8.0 + the foreground nav service + the **four hidden WebViews** (photos, transit, reviews, popular-times). Root package `app.vela`, app class `VelaApp`, config `VelaConfig`. ### Module tree ``` :core the "extractor" - no UI dependency, the NewPipeExtractor pattern ├─ model/ LatLng, Place, Route, Maneuver … (pure Kotlin) ├─ data/ │ ├─ MapDataSource the one seam every screen talks to │ ├─ MockMapDataSource canned data → the app runs with no network │ ├─ google/ the real scraper │ │ ├─ GoogleSession per-user bootstrap (token extraction) │ │ ├─ GoogleMapsDataSource search / directions / place details │ │ ├─ PbBuilder builds Google's `pb` URL protobuf │ │ ├─ GoogleResponse XSSI strip + positional-array navigator │ │ ├─ PolylineCodec encoded-polyline decode (calibration-free) │ │ └─ parse/ Search / Directions / Transit / Photos / Reviews │ ├─ RouteGeometry OSRM turn-by-turn (open router) + parse of steps/lanes/refs │ ├─ RouteEngine offline-routing interface (connectivity/graph-presence picks it) │ ├─ GraphHopperRouteEngine on-device GraphHopper CH graphs, one per downloaded region │ ├─ RouteCorridor "search along route" - filter results to the line │ ├─ OverpassPois keyless OSM POI + addr + street fetch (offline-search/geocode source) │ ├─ OfflinePoiStore on-device SQLite POI index (offline search) │ ├─ OfflineAddressStore on-device SQLite forward geocoder (typed address → coord, offline) │ ├─ OfflinePacks registry of downloaded whole-region place packs (both stores query them) │ └─ tiles/ MapStyle catalog (OpenFreeMap default / Positron / Protomaps) ├─ location/ LocationProvider - AOSP LocationManager (no Fused) ├─ voice/ VoiceGuide - AOSP TextToSpeech, engine-selectable ├─ feedback/ Haptics - direction-coded vibration turn cues ├─ config/ Calibration + CalibrationStore (remote pb/paths) ├─ nav/ NavEngine - pure turn-by-turn logic + NavReplay auditor (unit-tested) ├─ replay/ TripLog - trip CSV format + offline route audit └─ di/ Hilt wiring; picks Mock vs Google off VelaConfig :app Jetpack Compose UI (Material 3) ├─ MainActivity, VelaApp ├─ ui/map/ MapScreen, VelaMapView (MapLibre), MapViewModel, PoiIcons ├─ ui/search/ SearchBar ├─ ui/place/ PlaceSheet, DirectionsPanel, transit board, photo gallery ├─ ui/nav/ ManeuverBanner (lanes/shields/swipe), NavControls, StepsSheet ├─ ui/theme/ AppTheme - in-app light/dark, decoupled from the OS ├─ ui/ SheetPalette (one shared sheet palette), Format, Units ├─ web/ WebPhotoFetcher, WebDirectionsFetcher - hidden-WebView scrapes ├─ offline/ OfflineMaps (MapLibre tiles) + RoutingGraphStore (GraphHopper graphs) + PoiPackStore (place packs) + OverlayTileStore (building-footprint PMTiles) └─ ui/settings/ SettingsScreen (appearance / style / voice / haptics / keep-screen-on / offline) ``` ### Key seams - **`MapDataSource`** (`:core/data`) - the one interface the UI depends on. - `MockMapDataSource` (default; keeps the whole app usable offline) and - `google/GoogleMapsDataSource` (the real scraper). Flip with `VelaConfig.USE_GOOGLE_SOURCE`. - **`GoogleSession`** - bootstraps a logged-out session (cookies via an in-memory `InMemoryCookieJar` pre-seeded with Google's `SOCS`/`CONSENT` consent cookies so an EU session isn't bounced to `consent.google.com`). - **Process-wide reactive holders** (a `mutableStateOf` mirror + `SharedPreferences`, `init()`-ed in `VelaApp`): `ui/Units` (metric/imperial), `ui/theme/AppTheme` (Light/Dark/System - read via **`isAppInDarkTheme()`**, never `isSystemInDarkTheme()`), `ui/Traffic` (overlay on/off), `ui/TransitLayer` (rail highlight), `ui/LiveReviews` (reviews panel), `ui/PlaceContent` (`ShowReviews`/`LoadPhotos`/`HideAdult`), `ui/Buildings3d` (extrusion layer), `ui/AppLocale` (language), `ui/Onboarding`. ### Data flow (search example) `MapScreen` → `MapViewModel.search()` → `GoogleMapsDataSource.search()` → build `pb` (`SearchPb`) + `GET` → **optional JS override** (`JsTransforms`, §5) → `GoogleResponse.parse` → `SearchParser.parse` (positional indices from `Calibration.paths`) → **optional JS post-process** → `SearchResult` → UI. --- ## 3. The extractor (calibrated contract) > These field numbers / array indices are what *drift* when Google reshapes things. > The **live source of truth is [`calibration.json`](calibration.json)** (fetched at > runtime, §5); `Calibration.DEFAULT` is the compiled fallback and must be kept in > sync at release. [`docs/CALIBRATION.md`](docs/CALIBRATION.md) has the prose walk-through. > `PbBuilder` grammar and `PolylineCodec` are **stable**; field indices are **not**. ### Endpoints (all keyless, google.com only - host-allowlisted) | Purpose | Request | |---|---| | Search | `GET /search?tbm=map&q=&pb=` | | Directions (turn-by-turn) | **PRIMARY: FOSSGIS OSRM** `route/v1?steps=true` (`routed-car`/`-bike`/`-foot`) - full street-named maneuvers + geometry | | Directions (traffic ETA + fallback) | `GET /maps/preview/directions?pb=` - Google's live-traffic ETA/spans, overlaid on the OSRM route; also the fallback router | | Directions (traffic-aware path, option 3) | when Google's route diverges >700 m from OSRM's (jam reroute), OSRM `route/v1` **through ~12 vias sampled off Google's polyline** → Google's path with full OSRM steps. `/match` map-matching would be cleaner but FOSSGIS caps it at 10 coords - see "Why dense-via, not map-matching" | | Reviews | hidden WebView DOM-scrape of the place's own `?cid=` page (`WebReviewsFetcher`; the old `listentitiesreviews` RPC is dead, 404) | | Photos (full gallery) | `POST /maps/_/MapsWizUi/data/batchexecute?rpcids=hspqX` (proto in calibration) | | Transit | hidden WebView on `/maps/dir///data=!4m2!4m1!3e3` (see below) | | Reverse-geocode | OSM **Nominatim** `/reverse` (NOT Google - Google `tbm=map` won't reverse a lat,lng) | | (legacy) Google route geometry fallback | when OSRM is down, Google's directions + OSRM geometry fill | ### Search response (`root[64][i]`, each entry rooted at `[1]`) name `[1][11]` · full address `[1][39]` (fallback: join components `[1][2]`) · rating `[1][4][7]` · reviews `[1][4][8]` · lat `[1][9][2]` · lng `[1][9][3]` · category `[1][13][0]` · website `[1][7][0]` · phone `[1][178][0][0]` · price `[1][4][2]` (a dollar *range* "$10–20", not a 1–4 level - the tier flag lives in the `[1][4][9]` bucket tree; `SearchParser.priceLevelOf` derives a 1–4 from the label for the filter) · open-status `[1][203][1][8][0]` · rich status `[1][203][1][4][0]` · **feature id** `[1][10]` (`0x..:0x..` → reviews) · place id `[1][78]` · photos `[1][72][0][i][6][0]` (FIFE URLs, re-size `=w500-h350`; **moved from `[105]` 2026-06-27 - fix shipped via calibration v7**) · featured review `[1][142][1][0][1][0][0]` · About sections `[1][100][1]` (title `[s][1]`, items `[s][2][j][1]`) · **editorial one-liner** `[1][32][1][1]` · **owner "From the owner" blurb** `[1][154][0][0]` · weekly hours `[1][203][0]` (fallback `[1][118][0][3][0]`; 7 entries from today, name `[0]` + text `[3][0][0]`) · **in-store departments** `[1][118]` (the WHOLE list, path `departments`: each entry a named department "Safeway Pharmacy" / "Delivery" - name `[0]`, weekly hours `[3][0]` in the readHours shape, status `[3][1][4][0]`; `[203]` is the MAIN store's schedule, so status/hours always pair from the same block - taking status from `[118]` and hours from `[203]` was the "closes soon at 10 PM over a 5 AM–1 AM table" bug, live-probed 2026-07-08). A **far/specific address** is a single geocoded result at `[0][1][0][14]` (same schema), not a `[64]` list. **"People also search for"** is a sibling of a focused result at `root[2][11][0]` (path `similar`), each entry `[featureId, name, [[_,_,lat,lng], …, rating@6]]` - `SearchParser.parseSimilarPlaces` attaches it to the primary place (focused searches only; absent from multi-result lists). The `[84]` histogram **and** `[32]`/`[154]` descriptions are trimmed from the keyless/list response → fetched lazily via the WebView (`WebPopularTimesFetcher` → `PlaceDetails`). The same focused re-fetch **backfills the fields a *summary* node drops** - a suite/ multi-tenant address snaps to a light node missing review count / full hours / address / phone / price / attributes; the focused result is a full node, so `PopularTimesParser` lifts them into `PlaceDetails` (**feature-id-gated**) and `MapViewModel.fetchPlaceDetails` merges them into any blank field (additive; no match → unchanged). ### Directions response (`root[0][1][r]`, summary `[0]`) distance m `[2][0]` · typical dur s `[3][0]` · **traffic dur s `[10][0][0]`** · overall traffic level `[10][2]` · **typical spread `[10][4]` = `[lowSec, highSec, label]`** (Google's own depart-time hint, "usually 1 hr 8 min to 1 hr 27 min" → `Route.typicalRangeSeconds`) · **per-route geometry** = delta-encoded E7 arrays at `[0][7][i]` (lat deltas `[i][0]`, lng deltas `[i][1]`, first element absolute E7; `[i][4]` is elevation, NOT traffic) - index-aligned with summaries, so alternates draw real roads. **Per-segment live traffic** at `route[3][5][0]` (note: hangs off the route node, NOT the `[0]` summary) = `[level, startMeters, lengthMeters]` spans, only the non-free-flow stretches → `Route.trafficSpans` → the route line's colour bands. Steps are `` markup; lane hints ("Use the right 2 lanes…") split into `Maneuver.laneHint`. Each maneuver is placed at `cumulativeStepMeters/polyLength` along the geometry - **except the final (ARRIVE), which is pinned to the route end**: step distances total a few % short of the geometry, so the cumulative undershoots and once placed "arrive" ~15 km before a 134 km route's end (firing the 25 m arrival trigger there - a real test-drive bug, fixed 2026-06-20). **Predictive per-departure field: confirmed unreachable keyless** (2026-06-20, 6th probe): the response has no time-of-day curve, our `pb` is byte-identical to Google's live web client, injected time fields are ignored/400, and the web depart-time control is un-automatable → it's login/Android-app-only. We surface the typical spread (`[10][4]`, above) as the honest keyless stand-in; a true per-minute ETA needs one captured real depart-at request (mitmproxy on the Android app - see `ROADMAP.md`). ### Shared-list import (`/maps/preview/entitylist/getlist`, calibrated 2026-07-08) A maps.app.goo.gl share link fetched LOGGED-OUT redirects to `/maps/@/data=!4m3!11m2!2s!3e3`, and that page's HTML embeds a ready-made prefetch URL for the getlist RPC (list id + page session token included) - `GoogleMapsDataSource.importList` lifts it verbatim (regex on `/maps/preview/entitylist/getlist?...`, `&`-unescaped), so no pb is constructed anywhere. Response after the `)]}'` guard: `root[0][4]`=title · `[5]`=description · `[3][0]`=author · `[8]`=items, each `[2]`=display name, `[3]`=the owner's personal NOTE, `[1][4]`=address, `[1][5][2..3]`=lat/lng, `[1][6]`=feature id as a DECIMAL signed-int64 pair (two's-complement hex = the `0x..:0x..` reviews id - `EntityListParser.featureId`). Parser: `EntityListParser` (best-effort, unit-tested on a captured payload). ### Hours node (`[1][203][0]`) - date-specific, holidays baked in (observed 2026-07-01) Each day entry is `[name, dow(1=Mon..7=Sun), [Y,M,D], ranges, flag, flag, special?]` - a **rolling next-7-days** list keyed to the ACTUAL date, so **holiday overrides are already in it**: a Jul-4 bank showed `["Saturday",6,[2026,7,4],[["Closed"]],1,2,["4th of July hours","4th of July",1]]`. `ranges` = `[[text,[[openH],[closeH]]], …]` (MULTIPLE per day - split shifts); `special[1]` = a holiday label ("4th of July"). `readHours` joins all ranges and appends `" ·