Developers
Pathline Data API
Integration Guide · API Revision 2
The Pathline Data API lets another app on the same device read the user's
timeline (visits and trips) and raw recorded location samples from
Pathline, through an exported, read-only Android
ContentProvider.
Reaching the provider requires a single install-time permission your app declares
(auto-granted, no prompt); access to the data is then gated by custom runtime
permissions the user approves in the system permission dialog.
- On-device only. Nothing is sent off the device by this API; you read another local app's data via IPC.
- User-gated. A consumer only ever sees data for scopes the user has explicitly granted.
- Read-only. There is no way to write, modify, or delete Pathline data through this API.
- Audited. Every read — and any denied (unauthorized) attempt — is logged by Pathline and shown to the user, who can revoke access at any time. A denied attempt also notifies the user, so guard your queries (see §8).
Note
This guide targets API revision 2, available in Pathline 1.6.x and later. The authority, permission names, URI paths, and column names documented here are part of Pathline's stable contract — see Stability & versioning.
Contents
- How it works
- Requirements & compatibility
- Quick start
- Access & permissions
- Querying
- Data reference
- Complete example
- Best practices
- Privacy & transparency
- Stability & versioning
- Troubleshooting
- Appendix A — Contract constants
1. How it works
Pathline publishes a ContentProvider at the authority
net.extrawdw.apps.locationhistory.provider. You query a collection — usually by
passing a time window as URI query parameters — and read the results from the returned
Cursor.
The data model
Pathline organizes location data into three layers, from raw to derived:
- Samples — the raw, individual location fixes (a fused/GPS point with a timestamp, plus accuracy, speed, bearing, device state, …). The lowest level.
- Visits — stays: a continuous period spent at one place, derived by clustering samples. Carries a centroid, radius, and (when known) the matched place.
- Trips — the movements between stays, each with an inferred transport mode, a distance, and — for authorized callers — the route travelled.
Together, visits and trips form the user's timeline; samples are the raw
layer beneath it. A trip references the visits it connects via
from_visit_id / to_visit_id, so you can stitch a continuous
stay → move → stay timeline. Pick the layer your feature needs: the timeline for a
human-readable history, samples for raw analysis.
Collections
| Collection | Content URI | Required permission | Match semantics |
|---|---|---|---|
| Visits | content://net.extrawdw.apps.locationhistory.provider/visits | READ_TIMELINE | overlap |
| Trips | content://net.extrawdw.apps.locationhistory.provider/trips | READ_TIMELINE | overlap |
| Samples | content://net.extrawdw.apps.locationhistory.provider/samples | READ_LOCATION_HISTORY | point-in-window |
| Places | content://net.extrawdw.apps.locationhistory.provider/places | READ_TIMELINE | not windowed (see §5) |
| Place history | content://net.extrawdw.apps.locationhistory.provider/places/<id>/visits | READ_TIMELINE (+ READ_EXTENDED_HISTORY past 30 days) | overlap |
| Status | content://net.extrawdw.apps.locationhistory.provider/status | none (only the install-time gate) | single row; see §6 |
Important
The Places and Place history collections are access-scoped: you can only read a place's details (name, address) or its visit history after you have seen that place as the place_id of a confirmed visits row you were authorized to read. An unconfirmed visit — even one matched to a saved place — does not grant access on its own. A place you've never read a confirmed visit for returns nothing — this endpoint never widens what you can learn, it just lets you resolve the details of places you've already touched once, instead of re-receiving them on every visit row. See §5.
The access model
Reaching Pathline's data passes through three gates, from foundation up. All three must be open for a data read to succeed; §4 is the full reference.
- The install-time API gate — your app declares
…permission.APIwith<uses-permission>; the system auto-grants it (no prompt). It guards the whole provider: an app that doesn't declare it can't even resolve it, and every query — includingstatus— requires it. Necessary, but not sufficient on its own. - The user's access switch — one master toggle for all third-party data access (Pathline → Settings → Access to Pathline data). Defaults off; while off, every data read is denied no matter what permissions you hold. Check
statusfirst and request access if needed. - The runtime data permissions —
READ_TIMELINE,READ_LOCATION_HISTORY, and their refinements, each user-approved per scope in the system permission dialog.
The lone exception is status: it sits behind gate 1 like everything else, but bypasses gates 2 and 3 (no switch, no runtime permission) precisely so you can probe whether to read.
2. Requirements & compatibility
| Requirement | Detail |
|---|---|
| Pathline installed | The provider, permissions, and authority only resolve while Pathline is installed. |
| Pathline version | The Data API (revision 2) requires Pathline 1.6.x or later. |
| Android version | Android 14 (API 34) or newer — Pathline's minimum. |
| Package visibility | On Android 11+ (targetSdk 30+) you must declare Pathline in your <queries> to see the provider (see below). |
| Threading | ContentResolver.query is blocking — never call it on the main thread. |
Package visibility (Android 11+)
Add this to your consumer app's AndroidManifest.xml, as a direct child of
<manifest>, so your app can resolve Pathline's provider and permissions:
<queries>
<package android:name="net.extrawdw.apps.locationhistory" />
</queries>
Important
Without a <queries> entry (or QUERY_ALL_PACKAGES, which Google Play restricts), ContentResolver calls resolve to null and permission requests are silently denied on Android 11+. Prefer the targeted <queries> declaration above — do not request QUERY_ALL_PACKAGES just for this.
Detecting whether Pathline is installed
fun isPathlineInstalled(context: Context): Boolean =
runCatching {
context.packageManager.getPackageInfo("net.extrawdw.apps.locationhistory", 0)
true
}.getOrDefault(false)
3. Quick start
- Declare the scopes you need and the
<queries>entry in your manifest (§4). - Request the matching runtime permission(s) before reading (§4).
- Query the collection for a time window and read the
Cursoroff the main thread (§5).
Tip
Request only the scope you actually use. If your feature only shows the timeline, request READ_TIMELINE alone — don't ask for location-history or extended-history access you won't use. Users are far more likely to grant a narrow, obviously-justified request.
4. Access & permissions
Access is layered: the install-time API gate (gate 1) is the foundation — it guards the entire provider, including status. Above it, the user access switch (gate 2) governs all data reads, and the per-scope runtime permissions (gate 3) govern which data. All three must be open for a data read. This section covers each in turn, then how to declare, request, and handle errors.
The API gate (install-time)
The foundation every other gate rests on — one permission that stands apart from the rest:
| Permission (constant) | Protection level | Grants |
|---|---|---|
net.extrawdw.apps.locationhistory.permission.API | normal (install-time, auto-granted) | Permission to reach the provider at all — including the unguarded status route. |
Just declare it with <uses-permission>; the system grants it at install with no runtime prompt and nothing to request in code. It is a coarse "this app uses the Pathline API" marker — necessary but not sufficient: every data read still requires the relevant runtime permission(s) below. An app that doesn't declare it can't resolve or query the provider at all (the provider is exported but guarded by this permission), and any query without it throws SecurityException — there is no route, not even status, that answers without it.
The access switch
Past the API gate sits a single user-controlled access switch — Pathline → Settings → Access to Pathline data. It is the master gate (user-facing) for all third-party data access at once.
- Defaults off. The user opts in via a first-run consent screen (shown when an app first requests access) or the Settings toggle.
- While off, every data read is denied —
visits,trips,samples,places, andplaces/<id>/visitsall throwSecurityException, regardless of which runtime permissions you hold. Onlystatusstill answers (it sits behind the API gate alone). - A switch-off denial is logged but not notified. It's recorded in the audit log (attributed to the install-time
…permission.APIgate) but, unlike a per-app permission denial, does not alert the user — turning the whole API off is not a per-app breach. (Contrast: a denial because your app lacks a granted permission does notify — see §9.) - Don't fail silently. Read
status(access_enabled) before querying; if it's off, prompt with the request-access action instead of letting reads throw.
Note
The status endpoint is the one read that bypasses this switch (and needs no runtime permission), precisely so you can check it. It still requires the install-time API gate (gate 1), returns no personal data, and is never logged.
The data permissions (runtime)
These four are all protectionLevel="dangerous" (runtime, user-approved):
| Permission (constant) | Grants |
|---|---|
…permission.READ_TIMELINE | Read visits and trips (trip routes excluded — see below). |
…permission.READ_TIMELINE_ROUTE | Unlock the trips.encoded_polyline route column. Requested in addition to READ_TIMELINE. |
…permission.READ_LOCATION_HISTORY | Read raw location samples. Also unlocks trips.encoded_polyline. |
…permission.READ_EXTENDED_HISTORY | Read data older than 30 days — required in addition to the relevant base permission. |
A consumer must both declare a permission (manifest) and request it (runtime). Declaring without requesting, or requesting without declaring, will not grant access.
Choosing your scopes
These permissions form an escalating ladder — request the lowest tier your feature needs:
| Your feature needs… | Declare & request |
|---|---|
| Trip types, durations, distances, visits | READ_TIMELINE |
| …plus place details (names, addresses) and a place's visit history | READ_TIMELINE (same scope; access is place-scoped — see §5) |
| …plus drawing the route of each trip | READ_TIMELINE + READ_TIMELINE_ROUTE |
| Raw, per-point location fixes (timestamps, accuracy, speed…) | READ_LOCATION_HISTORY |
Note
READ_TIMELINE_ROUTE and READ_LOCATION_HISTORY belong to one permission group (both expose the precise path). On Android, once the user grants one permission in a group, your app's other declared permissions in that group are granted without a second prompt, and they are revoked together as one unit. So a reader that declares both gets them in a single consent; a routes-only reader that declares just READ_TIMELINE_ROUTE (not READ_LOCATION_HISTORY) still never gains sample access. READ_TIMELINE and READ_EXTENDED_HISTORY are standalone — each is its own independent consent.
Declare (consumer AndroidManifest.xml)
<!-- Required by every consumer: the install-time gate to reach the provider (auto-granted). -->
<uses-permission android:name="net.extrawdw.apps.locationhistory.permission.API" />
<uses-permission android:name="net.extrawdw.apps.locationhistory.permission.READ_TIMELINE" />
<!-- Only if you draw trip routes (or skip it and rely on READ_LOCATION_HISTORY): -->
<uses-permission android:name="net.extrawdw.apps.locationhistory.permission.READ_TIMELINE_ROUTE" />
<!-- Only if you need raw location samples: -->
<uses-permission android:name="net.extrawdw.apps.locationhistory.permission.READ_LOCATION_HISTORY" />
<!-- Only if you need data older than 30 days: -->
<uses-permission android:name="net.extrawdw.apps.locationhistory.permission.READ_EXTENDED_HISTORY" />
Request at runtime
val launcher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.values.all { it }
// Reload your data if granted; otherwise guide the user (see the OEM note below).
}
// Request only what the current feature needs:
launcher.launch(arrayOf(
"net.extrawdw.apps.locationhistory.permission.READ_TIMELINE",
))
The user can revoke any of these at any time in Settings → Apps → (your app) → Permissions.
Permission requests may not work on every device
Warning
Some Android skins (notably Xiaomi MIUI / HyperOS builds) do not support the third-party custom permissions — the request can be silently denied without any prompt.
Make your flow resilient: if the permission is still not granted after a request, fall back to deep-linking the user to your app's settings page so they can grant it manually. Be aware that some OEM permission managers also hide third-party custom permissions in their own UI; if so, the user may need to reach the stock Android permission controller (e.g. Settings → Apps → (your app) → Permissions) to find and grant them.
fun openAppSettings(context: Context) {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
Tip
Re-check permissions in onResume (e.g. with ContextCompat.checkSelfPermission) so your screen reloads automatically when the user returns from the Settings page after granting access.
Errors
| Condition | Thrown by query |
|---|---|
| The user's access switch is off (every data read) | SecurityException — check status and request access |
| Caller lacks the collection's base permission | SecurityException |
Window's start is older than 30 days and caller lacks READ_EXTENDED_HISTORY | SecurityException |
start query parameter missing (on any collection except places), or end <= start | IllegalArgumentException |
Invalid/missing place id in a places/<id>/visits path | IllegalArgumentException |
| Unknown collection path | IllegalArgumentException |
Note
start is required on every collection except places (the directory isn't windowed). For places/<id>/visits it's required just like visits — pass start=0 (plus READ_EXTENDED_HISTORY) for the whole history. A place you haven't yet seen in a visits read isn't an error either — it simply returns no rows.
Note
Lacking the route permission is not an error: a READ_TIMELINE-only caller's trips query succeeds, with encoded_polyline returned as null on every row. Always null-check that column (§6).
Caution
An over-30-day window without READ_EXTENDED_HISTORY is rejected, never silently narrowed. Either keep start within the last 30 days, or hold the extended-history permission — don't assume a clamped result.
5. Querying
Every collection is queried by a time window passed as URI query parameters:
content://net.extrawdw.apps.locationhistory.provider/<collection>?start=<epochMs>&end=<epochMs>
| Parameter | Required | Meaning |
|---|---|---|
start | Yes | Inclusive start of the window, epoch milliseconds (UTC). |
end | No | Exclusive end of the window, epoch milliseconds (UTC). Defaults to now. |
group | No | Batch-correlation key (see Grouping batched reads). Affects display only, never the data returned. |
Grouping batched reads
A single user-facing refresh often reads several collections at once (e.g. visits + trips + samples). By default those log as separate entries in Pathline's access manager. Pass an optional group key — the same value on every request in the batch — to have Pathline display them as one expandable access group.
- Value: a wall-clock epoch-ms number at/near "now" — use
System.currentTimeMillis()captured once at the start of the batch and reused. (NotelapsedRealtime.) - Validity: honored only when it lands essentially at "now" — between ~120 s before and a few seconds after the moment Pathline receives the request; stale, far-future, or non-numeric values are ignored (the read is still logged, just ungrouped). An invalid
groupnever causes an error. - Scope: grouping is per calling package — you can only group your own reads, never merge with another app's.
- Display only:
grouphas zero effect on what data a query returns. It also can't hide reads — Pathline still logs every individual request; grouped entries are expandable to the originals, and every request (with its group key) appears in the user's exportable log.
val batch = System.currentTimeMillis() // one value for the whole refresh
val visits = query("visits", start, now, group = batch)
val trips = query("trips", start, now, group = batch)
val samples = query("samples", start, now, group = batch)
Note
The 120 s validity is deliberate: a batch must complete within ~2 minutes to group. A long-running sync that spans more than that is several accesses over time and is shown as such — you can't make all-day activity look like a single access.
The places collection
visits and trips carry a place_id (and visits a convenience place_name), but the full place record — its address, category, source, and stable center — lives in the places collection. It works differently from the windowed collections:
- Not time-windowed.
start/endare ignored. Query…/placesfor all the places you're allowed to see, or passids— a comma-separated list — to fetch specific ones:…/places?ids=12,40,7. - Access-scoped. You only ever see a place after you've read a confirmed
visitsrow that referenced it (with a permission you held). An unconfirmed visit — even one matched to a saved place — does not grant access on its own. An unfiltered query returns exactly your granted set; anidsfilter is intersected with it, so ids you've never encountered are silently omitted (never an error). This means the typical flow is: readvisits→ collect the distinctplace_ids of confirmed rows → fetch their details fromplacesonce and cache them, instead of re-readingplace_nameon every visit. - Permission.
READ_TIMELINE— the same scope asvisits. (No window, soREAD_EXTENDED_HISTORYnever applies to the directory itself.)
A place's visit history
To list every visit at one place over time, query its history sub-collection: …/places/<place_id>/visits. These rows are deliberately lean — they carry no place_name/address, because the place is identified by the <place_id> in the path and you already have its details from places. This avoids re-sending the same saved-place info on every history row.
- Windowed (overlap).
startis required (epoch ms);enddefaults to now. Same overlap semantics asvisits, and the same ascendingstart_msordering — be explicit about how far back you read. - Extended history. A window starting more than 30 days ago requires
READ_EXTENDED_HISTORYin addition toREAD_TIMELINE. To read recent history with justREAD_TIMELINE, pass astartwithin the last 30 days; to read the whole history, passstart=0and holdREAD_EXTENDED_HISTORY. - Access-scoped. Only readable for a place you've been granted (seen on a confirmed
visitsrow). Its history then includes all that place's visits, confirmed or not. A place you haven't been granted — or one that doesn't exist — returns no rows (indistinguishable, so you can't probe ids).
val now = System.currentTimeMillis()
// All visits at place 42 in the last 30 days (READ_TIMELINE only):
val recent = Uri.parse("content://$AUTHORITY/places/42/visits").buildUpon()
.appendQueryParameter("start", (now - 30L*24*60*60*1000).toString()).build()
// Entire history at place 42 — start at the epoch; also needs READ_EXTENDED_HISTORY:
val all = Uri.parse("content://$AUTHORITY/places/42/visits").buildUpon()
.appendQueryParameter("start", "0").build()
Match semantics
- Timeline (
visits,trips) — overlap. Any item whose[start_ms, end_ms]span intersects the requested[start, end)window is returned in full, including items that began beforestartor end afterend. A request that lands in the middle of a stay or a journey returns that whole item — you never get truncated fragments. - Samples — point-in-window. A sample is returned when
start <= timestamp_ms < end. - Places — not windowed (directory); Place history — overlap, like
visits.
Ordering
The windowed collections are ordered ascending: visits, trips, and place history by start_ms; samples by timestamp_ms. The places directory has no guaranteed order (it's a keyed lookup, not a window) — sort it client-side if you need one, e.g. by name.
Ignored arguments
Note
projection, selection, selectionArgs, and sortOrder are ignored. Scope every query through the start / end URI parameters; the column set and ordering are fixed (see §6). Always resolve columns by name with Cursor.getColumnIndexOrThrow(...) rather than by fixed index.
Paging
Important
There is no paging — a query returns the entire matching set in one Cursor. A wide samples window can return tens of thousands of rows. Query in bounded windows (e.g. a day or a week at a time) and always read the cursor off the main thread. When you must cover a long span, split it into several bounded windows queried in sequence (optionally sharing one group key so they log as a single access). A future Pathline version may also cap the maximum window size.
6. Data reference
All times are epoch milliseconds, UTC. Nullable columns are marked ?. Each collection also advertises a directory MIME type via getType() — vnd.android.cursor.dir/vnd.net.extrawdw.apps.locationhistory.<visit|trip|sample|place|place_visit> — though ContentResolver.query never requires you to read it.
visits
| Column | Type | Notes |
|---|---|---|
_id | long | Stable visit id. |
start_ms | long | Stay start. |
end_ms | long | Stay end (equals "now" while the visit is ongoing). |
place_id | long? | Matched place id, or null when unconfirmed / candidate-only. |
place_name | string? | Matched place name, else candidate name, else null. |
latitude | double | Visit centroid latitude. |
longitude | double | Visit centroid longitude. |
radius_meters | double | Approximate radius of the stay. |
confidence | float | Place-attribution confidence, [0,1]. |
confirmed | int | 1 if the user confirmed the place, else 0. |
is_ongoing | int | 1 if this is the current open visit, else 0. |
trips
| Column | Type | Notes |
|---|---|---|
_id | long | Stable trip id. |
start_ms | long | Movement start. |
end_ms | long | Movement end. |
mode | string | One of the transport modes below. |
mode_confidence | float | Confidence of mode, [0,1]. |
distance_meters | double | Total distance traveled. |
encoded_polyline | string? | Route as a Google encoded polyline (precision 5). null unless the caller holds READ_TIMELINE_ROUTE or READ_LOCATION_HISTORY (§4). |
confirmed | int | 1 if the user confirmed the mode, else 0. |
from_visit_id | long? | Departure visit id, or null. |
to_visit_id | long? | Arrival visit id, or null. |
samples
| Column | Type | Notes |
|---|---|---|
_id | long | Stable sample id. |
timestamp_ms | long | Time of the fix. |
latitude | double | |
longitude | double | |
altitude | double? | Meters, or null. |
accuracy | float? | Horizontal accuracy radius in meters, or null. |
bearing | float? | Degrees, or null. |
speed | float? | Meters/second, or null. |
provider | string? | Source provider (e.g. fused, gps), or null. |
is_mock | int | 1 if reported as a mock location, else 0. |
device_state | string | One of the device states below. |
ar_activity | string? | Raw Activity-Recognition activity string, or null. |
network_transport | string? | One of the network transports below, or null. |
included_in_computation | int | 1 if used in timeline computation, 0 if excluded (e.g. mock / GPS drift). |
places
Directory MIME type vnd.android.cursor.dir/vnd.net.extrawdw.apps.locationhistory.place. Returned only for places you're allowed to see, in no guaranteed order (sort client-side).
| Column | Type | Notes |
|---|---|---|
_id | long | Stable place id. Matches visits.place_id. |
name | string | Human name of the place. |
address | string? | Postal / street address, or null if none. |
category | string? | Free-form category, or null. |
source | string | How the place was created: USER, MAPS, or INFERRED. |
google_place_id | string? | Linked Google Places id, or null. |
latitude | double | Place center latitude. |
longitude | double | Place center longitude. |
radius_meters | double | Approximate radius of the place. |
places/<id>/visits (place history)
Directory MIME type vnd.android.cursor.dir/vnd.net.extrawdw.apps.locationhistory.place_visit. Lean visit rows for one place — no place_name/address (resolve those once from places).
| Column | Type | Notes |
|---|---|---|
_id | long | Stable visit id. |
start_ms | long | Stay start. |
end_ms | long | Stay end (equals "now" while ongoing). |
place_id | long | The place — constant across the result; equals <id> in the path. |
latitude | double | Visit centroid latitude. |
longitude | double | Visit centroid longitude. |
radius_meters | double | Approximate radius of the stay. |
confidence | float | Place-attribution confidence, [0,1]. |
confirmed | int | 1 if the user confirmed the place, else 0. |
is_ongoing | int | 1 if this is the current open visit, else 0. |
status
Single-row, item MIME type vnd.android.cursor.item/vnd.net.extrawdw.apps.locationhistory.status. Always answerable — not gated by the access switch, needs no runtime permission, returns no personal data, and is not logged. It does still require the install-time API gate (like every route), so a caller that declares …permission.API can always read it. Query it to decide whether to read or to prompt the user.
| Column | Type | Notes |
|---|---|---|
access_enabled | int | 1 when the user has turned third-party access on; 0 means every data read is denied. |
To know which permissions your own app holds, check them directly with ContextCompat.checkSelfPermission(...) — the provider doesn't report them.
Requesting access
When status.access_enabled is 0, launch the request-access action for a result to open Pathline's onboarding so the user can turn the API on. Pathline shows it with your app's name and icon (and a note that it's the overall switch, not specific to you), then returns you to where you were — RESULT_OK when access ends up on, RESULT_CANCELED otherwise, with the result intent carrying the boolean extra access_enabled. If access is already on it returns RESULT_OK immediately without prompting.
private val requestAccess = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) reload() // access is now on (or re-read status)
}
// PathlineContract.Actions.requestApiAccessIntent(), targeted at Pathline:
requestAccess.launch(
Intent("net.extrawdw.apps.locationhistory.action.REQUEST_API_ACCESS")
.setPackage("net.extrawdw.apps.locationhistory")
)
Enumerations
String columns use fixed, stable token sets. Treat any unrecognized value as the UNKNOWN/null case.
| Column | Values |
|---|---|
trips.mode | WALKING, RUNNING, CYCLING, CAR, BUS, RAIL, FERRY, FLIGHT, UNKNOWN |
samples.device_state | STATIONARY, WALKING, RUNNING, CYCLING, IN_VEHICLE, UNKNOWN |
samples.network_transport | WIFI, CELLULAR, ETHERNET, VPN, OTHER, NONE |
places.source | USER, MAPS, INFERRED |
7. Complete example
A minimal, production-shaped read: declare → request (with OEM fallback) → query off the main thread.
object Pathline {
const val AUTHORITY = "net.extrawdw.apps.locationhistory.provider"
const val READ_TIMELINE = "net.extrawdw.apps.locationhistory.permission.READ_TIMELINE"
fun windowUri(collection: String, startMs: Long, endMs: Long): Uri =
Uri.parse("content://$AUTHORITY/$collection").buildUpon()
.appendQueryParameter("start", startMs.toString())
.appendQueryParameter("end", endMs.toString())
.build()
}
data class Visit(val id: Long, val name: String?, val startMs: Long, val endMs: Long)
/** Reads visits overlapping [startMs, endMs). Call from a background dispatcher. */
fun readVisits(resolver: ContentResolver, startMs: Long, endMs: Long): List<Visit> {
val out = ArrayList<Visit>()
resolver.query(Pathline.windowUri("visits", startMs, endMs), null, null, null, null)?.use { c ->
val id = c.getColumnIndexOrThrow("_id")
val name = c.getColumnIndexOrThrow("place_name")
val start = c.getColumnIndexOrThrow("start_ms")
val end = c.getColumnIndexOrThrow("end_ms")
while (c.moveToNext()) {
out += Visit(
id = c.getLong(id),
name = if (c.isNull(name)) null else c.getString(name),
startMs = c.getLong(start),
endMs = c.getLong(end),
)
}
}
return out
}
Driving it from a screen, with the permission flow and the OEM fallback:
private val needed = arrayOf(Pathline.READ_TIMELINE)
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { if (it.values.all { granted -> granted }) loadTimeline() else /* show "Open settings" hint */ }
private fun ensureAccessThenLoad() {
val missing = needed.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) loadTimeline() else permissionLauncher.launch(missing.toTypedArray())
}
private fun loadTimeline() {
lifecycleScope.launch(Dispatchers.IO) {
val now = System.currentTimeMillis()
val visits = try {
readVisits(contentResolver, now - 24 * 60 * 60 * 1000L, now)
} catch (e: SecurityException) {
emptyList() // permission missing/revoked — prompt or deep-link to settings
}
withContext(Dispatchers.Main) { render(visits) }
}
}
Tip
To read further back than 30 days, widen start and hold READ_EXTENDED_HISTORY — otherwise the query throws SecurityException.
8. Best practices
Important
Always guard the query with a permission check. Call ContextCompat.checkSelfPermission(...) for each required scope and only query when it's granted; otherwise request the permission (or send the user to Settings). A query you aren't authorized for throws SecurityException, is recorded in the user-visible access log as a denied attempt, and immediately notifies the user that your app tried to reach a scope it isn't permitted — so never call the provider speculatively to "see if it works."
fun canRead(context: Context, vararg permissions: String): Boolean =
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
// Guard, don't probe:
if (canRead(context, Pathline.READ_TIMELINE)) {
readVisits(contentResolver, start, now) // safe — permission held
} else {
requestPermissions(arrayOf(Pathline.READ_TIMELINE)) // ask first
}
- Query in bounded windows. Prefer a day or a week per query, especially for
samples. There is no paging. - Always go off-thread.
queryis blocking IPC; use a background dispatcher /WorkManager. - Resolve columns by name. Use
getColumnIndexOrThrowand handle nullables withCursor.isNull. - Request the narrowest scope. Only ask for
READ_EXTENDED_HISTORYwhen the user actually navigates past 30 days. - Handle absence and revocation gracefully. Pathline may be uninstalled, or a permission revoked, between launches — catch
SecurityExceptionand re-check on resume. - Don't hardcode column indices or assume ordering beyond the documented ascending sort.
9. Privacy & transparency
Pathline treats third-party access as a first-class privacy surface: every interaction is recorded on-device and shown to the user, who can revoke any app at any time. Design your integration assuming the user sees exactly what you do.
Important
Pathline logs both of these to the audit trail under Settings → Access to Pathline data, and the user can revoke any app's access from there:
- Every successful read — which app, which collection, the requested window, and the number of rows returned.
- Every denied attempt — a well-formed request rejected because your app lacks the permission. It reads nothing, is shown as "Denied", and — unlike a read — notifies the user immediately that your app tried to reach a scope it isn't permitted.
A speculative or unguarded query is therefore both visible and actively surfaced to the user. Always guard your query with a permission check and request the scope first — never probe to "see if it works."
- Read the minimum, on demand. Request the narrowest scope, over the smallest window, in clear response to a user action — bursty or unexplained access is visible to the user.
- On-device only. The audit metadata never leaves the device and is not included in Pathline's encrypted backups.
- Your data handling is your responsibility. This API never exposes another user's data and never leaves the device by itself; what your app does with what it reads is on you (and subject to Google Play's policies).
Notification behavior
Pathline proactively alerts the user about third-party access. Assume both of the following will surface as user notifications:
- On a successful read — a separate, per-app notification (your app's icon on the right, Pathline's on the left) naming your app and summarizing what was read (e.g. "Acme accessed 6 visits, 2,636 location samples."). Reads are coalesced (a burst becomes one alert) and rate-limited per app with an escalating back-off (next alert waits ~1h, then 2h, 4h … up to 24h), so a frequently-reading app won't spam the user. The back-off resets after ~48h quiet.
- On a denied attempt — a separate, immediate notification ("Acme tried to access Location history without permission."). It fires without the coalescing delay because a blocked access is a security-relevant event the user should hear about right away. It rides its own per-app back-off lane (so it never displaces, or is muffled by, the read alert) with the same escalating limit for repeats.
- Standing-access reminder — periodically (about weekly), Pathline reminds the user which apps still hold access, so a granted-and-forgotten app is surfaced for review.
All of these use a dedicated, default-importance "Data access alerts" notification channel the user can tune or mute in system settings, and deep-link into Settings → Access to Pathline data. The user can reset all back-off timers from there, so the next access (read or denied) alerts again at once.
Note
Permissions themselves are standard Android runtime permissions — granting/revoking happens in the OS permission UI, and Pathline sends no notification for a grant or revoke. The alerts above are about reads, denied attempts, and standing access, not permission changes.
Tip
Because access is user-visible and notified, do your reads in clear response to a user action (opening your timeline screen, a widget refresh) rather than silent, high-frequency background polling — and never query a scope you haven't been granted.
10. Stability & versioning
The following are part of the stable contract for API revision 2 and will not change without a new API revision:
- The authority
net.extrawdw.apps.locationhistory.provider. - The collection paths
visits,trips,samples,places,places/<id>/visits, andstatus. - The access-switch behavior and the
…action.REQUEST_API_ACCESSrequest action. - The permission names — including the install-time gate
…permission.API— the query-parameter names (start,end,ids), and the column names in §6.
Note
New columns or collections may be added in place; treat unknown columns/values defensively and don't assume a fixed column count. The 30-day boundary for READ_EXTENDED_HISTORY is a fixed product rule.
Warning
Change notifications are not delivered. A Cursor from this provider sets a notification URI, but Pathline does not currently call notifyChange, so a ContentObserver will not fire when data updates. Poll on your own cadence (e.g. on screen open or via WorkManager); do not rely on observers.
11. Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
query returns null | Pathline not installed, or missing <queries> visibility (§2). |
SecurityException on every call | Missing the install-time …permission.API gate — declare it with <uses-permission> (§4). |
SecurityException on every data read despite holding permissions | The user's access switch is off — query status and request access. |
SecurityException | Missing base permission, or an over-30-day window without READ_EXTENDED_HISTORY (§4). |
IllegalArgumentException | Missing start, end <= start, or an unknown path (§4). |
| No permission dialog appears | OEM skin suppressing third-party custom-permission prompts — deep-link to app settings (§4). |
| Empty results for a valid window | Permission granted but the user simply has no data in that window, or all samples were excluded. |
places / place history returns nothing | You haven't read a confirmed visits row for that place yet — access is place-scoped (§5). Read visits first, then query the place(s) from its confirmed rows. |
encoded_polyline is always null | Caller holds READ_TIMELINE but neither READ_TIMELINE_ROUTE nor READ_LOCATION_HISTORY (§4). |
| Observer never fires | Expected — no change notifications; poll instead (§10). |
Appendix A — Contract constants
Pathline ships the canonical, dependency-free contract at net.extrawdw.apps.locationhistory.api.PathlineContract — prefer copying that file verbatim to avoid string literals. The excerpt below is the string-constant subset for quick reference; the shipped file additionally provides a typed CONTENT_URI and CONTENT_TYPE per collection and a COLUMNS array for each.
Download PathlineContract.kt (revision 2)
object PathlineContract {
const val AUTHORITY = "net.extrawdw.apps.locationhistory.provider"
/** Windows reaching further back than this need READ_EXTENDED_HISTORY. */
const val EXTENDED_HISTORY_WINDOW_MS = 30L * 24 * 60 * 60 * 1000
object Permissions {
/** Install-time gate (normal); declare to reach the provider. Necessary, not sufficient. */
const val API = "net.extrawdw.apps.locationhistory.permission.API"
const val READ_TIMELINE = "net.extrawdw.apps.locationhistory.permission.READ_TIMELINE"
const val READ_TIMELINE_ROUTE = "net.extrawdw.apps.locationhistory.permission.READ_TIMELINE_ROUTE"
const val READ_LOCATION_HISTORY = "net.extrawdw.apps.locationhistory.permission.READ_LOCATION_HISTORY"
const val READ_EXTENDED_HISTORY = "net.extrawdw.apps.locationhistory.permission.READ_EXTENDED_HISTORY"
}
object QueryParams {
const val START = "start" // inclusive, epoch ms (required, except on `places`)
const val END = "end" // exclusive, epoch ms (optional, defaults to now)
const val GROUP = "group" // optional batch key: epoch ms ~now, within 120s of receipt
const val IDS = "ids" // optional comma-separated place-id filter for `places`
}
object Visits {
const val PATH = "visits"
const val ID = "_id"
const val START_MS = "start_ms"
const val END_MS = "end_ms"
const val PLACE_ID = "place_id"
const val PLACE_NAME = "place_name"
const val LATITUDE = "latitude"
const val LONGITUDE = "longitude"
const val RADIUS_METERS = "radius_meters"
const val CONFIDENCE = "confidence"
const val CONFIRMED = "confirmed"
const val IS_ONGOING = "is_ongoing"
}
object Trips {
const val PATH = "trips"
const val ID = "_id"
const val START_MS = "start_ms"
const val END_MS = "end_ms"
const val MODE = "mode"
const val MODE_CONFIDENCE = "mode_confidence"
const val DISTANCE_METERS = "distance_meters"
const val ENCODED_POLYLINE = "encoded_polyline"
const val CONFIRMED = "confirmed"
const val FROM_VISIT_ID = "from_visit_id"
const val TO_VISIT_ID = "to_visit_id"
}
object Samples {
const val PATH = "samples"
const val ID = "_id"
const val TIMESTAMP_MS = "timestamp_ms"
const val LATITUDE = "latitude"
const val LONGITUDE = "longitude"
const val ALTITUDE = "altitude"
const val ACCURACY = "accuracy"
const val BEARING = "bearing"
const val SPEED = "speed"
const val PROVIDER = "provider"
const val IS_MOCK = "is_mock"
const val DEVICE_STATE = "device_state"
const val AR_ACTIVITY = "ar_activity"
const val NETWORK_TRANSPORT = "network_transport"
const val INCLUDED_IN_COMPUTATION = "included_in_computation"
}
object Places {
const val PATH = "places"
const val ID = "_id"
const val NAME = "name"
const val ADDRESS = "address"
const val CATEGORY = "category"
const val SOURCE = "source" // USER | MAPS | INFERRED
const val GOOGLE_PLACE_ID = "google_place_id"
const val LATITUDE = "latitude"
const val LONGITUDE = "longitude"
const val RADIUS_METERS = "radius_meters"
const val VISITS_PATH = "visits" // content://…/places/<id>/visits
fun visitHistoryUri(placeId: Long): Uri =
Uri.parse("content://$AUTHORITY/$PATH/$placeId/$VISITS_PATH")
// Lean visit-history rows (no place_name/address — resolve once from the place above).
object VisitHistory {
const val ID = "_id"
const val START_MS = "start_ms"
const val END_MS = "end_ms"
const val PLACE_ID = "place_id"
const val LATITUDE = "latitude"
const val LONGITUDE = "longitude"
const val RADIUS_METERS = "radius_meters"
const val CONFIDENCE = "confidence"
const val CONFIRMED = "confirmed"
const val IS_ONGOING = "is_ongoing"
}
}
object Status {
const val PATH = "status"
const val ACCESS_ENABLED = "access_enabled"
}
object Actions {
const val REQUEST_API_ACCESS = "net.extrawdw.apps.locationhistory.action.REQUEST_API_ACCESS"
const val EXTRA_ACCESS_ENABLED = "access_enabled" // boolean result extra
fun requestApiAccessIntent(): Intent = Intent(REQUEST_API_ACCESS).setPackage(PACKAGE)
}
const val PACKAGE = "net.extrawdw.apps.locationhistory"
}