Export your Google Maps Timeline (the 2024+ on-device "Location History") to JSON — fetched straight from Google and decoded locally.
The point of the project: recover Google's own placeId for every visit — its
building-level place match, which raw GPS coordinates cannot reproduce — plus semantic
classification, transport modes and trips, as machine-readable JSON.
Nothing here needs an Android device. An optional path via an Android container also exists (see Appendix: the container path) but is no longer required for anything.
pip install 'httpx[http2]' cryptography gpsoauth
# 1. sign in normally (2FA and all), in your own browser, at:
# https://accounts.google.com/embedded/setup/android?source=com.google.android.gms&xoauth_display_name=Android%20Device
pip install browser-cookie3 # lets step 1's cookie be picked up automatically
python3 get_token.py --email you@example.com --from-browser \
--save-master ~/timeline-data/master.txt --out ~/timeline-data/tok.txt
# 2. one-time: fetch the decryption key with a browser (answer the password prompt)
python3 web_key.py --email you@example.com \
--master-token-file ~/timeline-data/master.txt --headful -o ~/timeline-data/key.b64
# 3. fetch + decode + build records
./export_all.sh --cloud--from-browser reads the oauth_token cookie your browser received — the same value you
would otherwise copy from DevTools (--oauth-token-file still works if you prefer that, or
use another browser with --from-browser firefox). The sign-in itself is not automated:
Google blocks automation-driven sign-in, and this tool does not try to get around that.
--from-browser reads a cookie store on the same machine. If your browser is
elsewhere — you signed in on your laptop but run this on a server, or the box is headless —
there is nothing local to read. Sign in wherever you like, copy the oauth_token value
across (DevTools → Application → Cookies → accounts.google.com), and use
--oauth-token-file instead. Only that one short-lived, single-use value needs to travel;
your password and second factor never leave the machine you signed in on.
No --android-id needed: the tokens bind to a 64-bit id, and one is generated on first
run and saved to $TIMELINE_OUT/android_id for reuse. Keep that file — every later token
exchange must present the same value. Pass --android-id explicitly only if you want to
bind to a device you already own.
Afterwards only step 3 is routine. The bearer from step 1 lasts about an hour and is refreshed from the saved master token without the browser:
python3 get_token.py --email you@example.com \
--master-token-file ~/timeline-data/master.txt --out ~/timeline-data/tok.txtGenerated data (the fetched DB, exports, name caches, tokens, the key) is written to
$TIMELINE_OUT, default ~/timeline-data — deliberately outside the checkout.
That is a safety property, not a preference. If a working copy holds real location data, real values end up pasted into comments, docstrings and examples while you edit — which is exactly how a real city and postcode once reached this repo's history. Keep the checkout data-free and any example you write is necessarily synthetic, because the real values simply are not there.
Before pushing, run tools/precommit-scan.sh (case-insensitive, scans history too, and
reads a git-ignored .private-denylist of your own terms).
key.b64 decrypts your entire location history and master.txt is a long-lived
account credential. Both are written mode 600; treat them like passwords. Revoke the
master token at https://myaccount.google.com/device-activity.
Google's own Play services fetch this data over a private gRPC service, and this client speaks the same protocol. Wire shapes come from microG GmsCore (Apache-2.0), an open-source clean-room implementation:
| endpoint | geller-pa.googleapis.com |
| RPC | /geller.oneplatform.GellerService/BatchSync (gRPC over HTTP/2) |
| corpus | GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY (79) |
| scope | https://www.googleapis.com/auth/webhistory |
| crypto | AES-256-GCM, 12-byte IV ‖ ciphertext+tag, 32-byte key |
The response carries GellerElements whose payloads unwrap
GellerAny → GellerE2eeElement → (decrypt) → ExternalDbSync → ExternalDbSnapshot → rows;
the semantic_segment column of those rows is the protobuf the decoder reads.
content-type must be exactly application/grpc — application/grpc+proto returns 404.
The key lives in the on_device_location_history security domain. web_key.py gets it
with a browser: it drives the same accounts.google.com/encryption/unlock/android page
Play services uses, standing in a window.mm shim where Android has its WebView JS
bridge, and captures the material the page delivers via setVaultSharedKeys. Google
interposes a password re-auth challenge (not a lock-screen factor), so run --headful and
answer it. extract_key.py is an alternative if you have a device already enrolled in the
domain — it reads the same key from files/folsom/shared/FolsomKeyStore.pb, where this
domain stores it unwrapped.
The key is domain-wide and stable across devices; re-run only if Google rotates it (the store records an epoch).
ODLH = On-Device Location History. geller_fetch.py writes it as
$TIMELINE_OUT/odlh-storage.db; on an Android device Play services keeps the same store at
/data/data/com.google.android.gms/databases/odlh-storage.db. Table semantic_segment_table, one row per segment; the
semantic_segment column is a protobuf blob (Google's SemanticSegment message).
This is the same data Google's own Export Timeline data button emits — but that
button is a fragile SAF-picker UI flow (and is missing from some Maps builds), useless
for a pipeline. Reading the DB is the robust path.
segment_type: 1 = visit, 2 = activity, 3 = timelinePath (raw GPS, ~2 h buckets),
4 = trip.
Top-level {"semanticSegments": [...]}, matching Google's on-device export
(visit / activity / timelinePath). Times are ISO-8601 with the segment's UTC
offset; coordinates are "lat°, lng°" strings (E7 → degrees). (Values below are
synthetic placeholders.)
See sample-output.json for a complete synthetic example.
The blob stores Google's internal FeatureId = (cellId, fprint), two 64-bit ints.
The public ChIJ… placeId used by Maps URLs and the Places API is just those bytes
re-wrapped:
placeId = base64url( 0x0a 0x12 0x09 <cellId as little-endian fixed64> 0x11 <fprint LE fixed64> )
So featureId and placeId are the same identity in two encodings. Resolve a
placeId to a name/address/category with:
- a browser (logged in): open
placeUrl; - the Places API (needs a key):
GET places/{placeId}?fields=displayName,formattedAddress,types; ?cid=URL:https://maps.google.com/?cid=<decimal fprint>.
Names/categories are not cached on-device in any joinable form (checked — the only
exception is your manually-saved places in gmm_myplaces.db). So name resolution is a
separate enrichment step over the placeId — see Resolving place names below. Two
paths: a browser renderer (no API key) or the Places API (key).
semanticTypeCodeis the raw protobuf enum — always trust it.semanticTypelabel:1 → HOMEis confirmed — its location matches the on-device HOME alias ingmm_myplaces.db → sync_itemkey0:0. The rest are best-effort:0 = UNKNOWN/inferred (the bulk of visits),4 = INFERRED(a frequently-visited place that is not the labelled WORK alias),5 = SEARCHED_ADDRESS(rare).2 = WORKper Google's convention (may not appear). The enum is Google's, so1 = HOMEholds for any account.placeTypeCode(#1000) is Google's numeric place-type taxonomy (restaurant/park/…) — hundreds of codes, no public mapping table shipped here; emitted raw. It is coarser than a category name and is not a substitute for name resolution: measured against resolved categories, only a small minority of observed codes map to a single category ≥80% of the time (a few are reliable; many are not). Use it for grouping, not labelling.visit.isConfirmedis set when you explicitly confirmed the visit in Maps (VisitProto.is_confirmed) — a high-trust subset.PlaceCandidate.top_place_type(#7) exists but is empty in practice (empty on essentially every visit observed), so it is not emitted.
The field numbering here was originally derived from the raw protobuf wire format, then
cross-checked against microG GmsCore's segment.proto (Apache-2.0, PR #3331),
an independent clean-room definition of the same message. They agree on
LocationHistorySegmentProto{start_time=1, end_time=2, segment_data=3, is_deleted=4, segment_id=6, finalization_status=9, display_mode=10, source=11, metadata=12}, on the
visit=1 / activity=2 / path=3 union, and on
PlaceCandidate{feature_id=1, semantic_type=2, probability=3, location=5}.
One documented disagreement: microG names fields 7/8 hierarchy_level /
finalization_state. Observed data contradicts that — they are consistently equal to each other and only ever the local standard-time or DST offset,
switching with the season. This decoder therefore reads them as start/end UTC offset
minutes, which is also what makes the emitted timestamps line up with reality.
Segments flagged is_deleted are skipped (you deleted them; they shouldn't reappear
in an export). Pass --include-deleted to keep them.
timelinePathbuckets carry no UTC offset in the store; each inherits the nearest neighbouring segment's offset. The seed default is+120; change it inodlh_export.py(last_off) to your account's base offset if needed.durationMinutesOffsetFromStartTimeis one byte per point; treated as minutes from the bucket start. A few large values suggest the unit isn't always plain minutes — the points themselves are exact, the per-point time is approximate.--statsprints per-type counts pluserrors(decode failures are also logged to stderr). Note a blob that parses but is empty still counts as decoded, soerrors=0means "nothing threw", not "everything was rich".
Turn placeIds into Google's real place names/addresses. Both paths resolve by placeId (Google's own building-level identity) — never by reverse-geocoding coordinates, which would throw away exactly the disambiguation this project exists for.
Google serves place names only via client-side JS, so render the public place page in
headless Chromium and read the resolved /maps/place/<NAME>/…!1s<ftid>… URL. It
cross-checks the resolved ftid against the one requested, so a merged/moved place is
flagged, not mislabelled.
npm install # puppeteer-core (needs a system Chromium/Chrome)
./get_consent_cookie.sh $TIMELINE_OUT/consent_cookies.txt # anonymous EU cookie-consent
node resolve_names.js "$TIMELINE_OUT/Timeline-latest.json" -o "$TIMELINE_OUT/Timeline-named.json"Adds placeName + placeAddress to every visit; caches in $TIMELINE_OUT/place_cache_browser.json
(resumable). Env knobs: CHROME_PATH, RESOLVE_DELAY_MS (default 2500), RESOLVE_LIMIT
(0 = all). Pace it — rendering 300+ Maps pages fast from one IP can trip Google
throttling/CAPTCHA; the default delay is deliberately conservative, and the cache means
you resolve each place only once, ever. This is scraping, so it's on you to keep it
gentle and within Google's ToS.
Official, fast, richer (adds placeCategory/types). Needs a Google Maps Platform key
with Places API (New); 300-odd cached lookups sit inside the free tier. See the script
header for setup. Use this if you want categories or a fully supported path.
Either way, resolution sends Google its own placeIds for your own visited places — no new disclosure (unlike shipping coordinates to a third party).
export_all.sh runs this after resolution to produce human-legible records — deterministic,
no LLM:
- Enriched visits — each visit's
topCandidategainsplaceName, a properplaceAddress(street/number/postcode, not the name repeated),placeCategory, andplaceLocation.{town,country,postalCode}(town & country parsed from Google's own address, or taken structured from the Places API'saddressComponents). - Rich trips — a top-level
trips[], each with metadata, adestination(dominant away-town), a one-line humandescription,stats(stops / distanceKm / kmByMode),topPlaces, and the full GPStrackassembled from the timelinePath in the trip window:
"trips": [{ // values below are illustrative placeholders
"id": "...", "startDate": "2025-01-05", "endDate": "2025-01-08", "durationDays": 4,
"destination": "Springfield, Exampleland",
"description": "Jan 5–8, 2025 · 4 days · Springfield, Exampleland · 600 km (mostly car) · 15 stops: …",
"stats": { "stops": 15, "distanceKm": 600.0,
"kmByMode": {"in passenger vehicle": 590.0, "walking": 10.0}, "trackPoints": 300 },
"topPlaces": [{ "name": "…", "town": "Springfield", "visits": 3, "hours": 48.0 }],
"track": [[12.345678, 98.765432, "2025-01-05T12:08:00+02:00"], "…"]
}]- Movement segments — a top-level
movements[], one per transport leg, linked to its origin and destination places (from the adjacent visits) with mode, distance, duration, speed, a humandescription, and its own GPS route:
"movements": [{
"startTime": "…", "endTime": "…",
"mode": "in passenger vehicle", "modeCode": 29, "speedKmh": 22.4,
"distanceKm": 4.2, "durationMin": 12,
"from": { "name": "Home", "town": "Springfield", "semanticType": "HOME" },
"to": { "name": "Central Park", "town": "Springfield" },
"description": "12 min · car · 4.2 km · Home → Central Park",
"track": [[12.345678, 98.765432, "…"], "…"]
}](The same from/to/description are also folded into each activity segment inline.)
Deterministic modal-split report over the activity segments — no LLM, no network:
python3 travel_mode.py "$TIMELINE_OUT/Timeline-latest.json" # modal split + longest + cross-check
python3 travel_mode.py "$TIMELINE_OUT/Timeline-latest.json" --trips # + per-trip mode breakdown
python3 travel_mode.py "$TIMELINE_OUT/Timeline-latest.json" --json # machine-readable summaryUses Google's own per-activity mode (activity.topCandidate.type/typeCode, emitted by
odlh_export.py) plus each segment's distance and duration. Reports km/time/count per
mode, the longest journeys, a per-trip breakdown, and an independent speed cross-check
that flags any move whose speed is implausible for its labelled mode (surfaces both
Google misclassifications and GPS-jitter artifacts — it's how the code 7 = vehicle, not cycling label was pinned). Mode labels: 2/5/29 are speed-verified (walking/flying/
vehicle); the rest are best-effort over Google's raw enum, and typeCode is always kept.
Before the direct fetch existed, this project drove an Android container (redroid) to make
Play services sync the Timeline, then read the resulting odlh-storage.db. That still
works and needs no token or key, but it is slower, far heavier, and lags behind the cloud
by however long Google's backup/import cycle takes.
setup.sh automates the whole bring-up: a resolution-pinned redroid container, the
lmkd/display stability fixes it needs on a KVM-less host, optional Play Integrity
(redroid/play_integrity.sh), sign-in (login.sh), the backup import (reimport.sh,
fixed-coordinate taps verified against the GMS log), and a nightly refresh. export_all.sh
without --cloud decodes from the container.
redroid runs Android on the host Linux kernel and needs binder/ashmem, so the
container path is Linux-only; Docker Desktop on macOS/Windows will not run it. The
processing tools are pure Python/Node and run anywhere.
See docs/microg-path.md for notes on replacing that stack with microG, kept for
reference — the direct fetch made it unnecessary.
get_token.py— browser sign-in → OAuth bearer for the Timeline scope.web_key.py— one-time: fetch the security-domain key with a browser.extract_key.py— one-time alternative: read it from an already-enrolled device.geller_fetch.py— fetch from Google →odlh-storage.db.odlh_export.py— decode that DB →{"semanticSegments": [...]}(stdlib only).build_records.py— enriched visits + movements + trips.resolve_names.js/get_consent_cookie.sh— place names via headless Chromium (no key).place_names.py— place names via the Places API (keyed).travel_mode.py— travel-mode / modal-split analyzer.export_all.sh— one command: fetch (or decode) → resolve → records.setup.sh,login.sh,reimport.sh,fetch_and_export.sh,redroid/*— the container path.tools/precommit-scan.sh— local leak scan before pushing.sample-output.json— synthetic example of the output schema.