Developers

Pathline Data API

Integration Guide · API Revision 1

Archived · Revision 1 Pathline 1.5.0 (version code 12) Revision history Download contract

Archived revision

This is the guide for API revision 1 (Pathline 1.5.x). The current revision is 2, available in Pathline 1.6.x and later — see the current integration guide. This page is kept so integrations built against revision 1 can still reference the exact contract they target.

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.

Note

This guide targets API revision 1, first available in Pathline 1.5.0 (version code 12). The authority, permission names, URI paths, and column names documented here are part of Pathline's stable contract — see Stability & versioning.

Contents

  1. How it works
  2. Requirements & compatibility
  3. Quick start
  4. Permissions
  5. Querying
  6. Data reference
  7. Complete example
  8. Best practices
  9. Privacy & transparency
  10. Stability & versioning
  11. Troubleshooting
  12. Appendix A — Contract constants

1. How it works

Pathline publishes a ContentProvider at the authority net.extrawdw.apps.locationhistory.provider. You query one of three collections 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:

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

CollectionContent URIRequired permissionMatch semantics
Visitscontent://net.extrawdw.apps.locationhistory.provider/visitsREAD_TIMELINEoverlap
Tripscontent://net.extrawdw.apps.locationhistory.provider/tripsREAD_TIMELINEoverlap
Samplescontent://net.extrawdw.apps.locationhistory.provider/samplesREAD_LOCATION_HISTORYpoint-in-window

Before any of this, your app must hold the install-time API gate permission net.extrawdw.apps.locationhistory.permission.API — declare it with <uses-permission> and the system auto-grants it (no prompt). An app that doesn't declare it can't even resolve the provider, let alone query it. It's necessary but not sufficient: every read still needs the relevant runtime permission(s) below.

Two refinements layer on top of the base permission (see §4):

2. Requirements & compatibility

RequirementDetail
Pathline installedThe provider, permissions, and authority only resolve while Pathline is installed.
Pathline versionThe Data API (revision 1) is available in Pathline 1.5.0 (version code 12) and later.
Android versionAndroid 14 (API 34) or newer — Pathline's minimum.
Package visibilityOn Android 11+ (targetSdk 30+) you must declare Pathline in your <queries> to see the provider (see below).
ThreadingContentResolver.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

  1. Declare the scopes you need and the <queries> entry in your manifest (§4).
  2. Request the matching runtime permission(s) before reading (§4).
  3. Query the collection for a time window and read the Cursor off 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. Permissions

The API gate (install-time)

One permission stands apart from the rest:

Permission (constant)Protection levelGrants
net.extrawdw.apps.locationhistory.permission.APInormal (install-time, auto-granted)Permission to reach the provider at all.

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 read still requires the relevant runtime permission(s) below. An app that doesn't declare it can't resolve or query the provider, and a query without it throws SecurityException.

The data permissions (runtime)

These four are all protectionLevel="dangerous" (runtime, user-approved):

Permission (constant)Grants
…permission.READ_TIMELINERead visits and trips (trip routes excluded — see below).
…permission.READ_TIMELINE_ROUTEUnlock the trips.encoded_polyline route column. Requested in addition to READ_TIMELINE.
…permission.READ_LOCATION_HISTORYRead raw location samples. Also unlocks trips.encoded_polyline.
…permission.READ_EXTENDED_HISTORYRead 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, visitsREAD_TIMELINE
…plus drawing the route of each tripREAD_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

ConditionThrown by query
Caller lacks the collection's base permissionSecurityException
Window's start is older than 30 days and caller lacks READ_EXTENDED_HISTORYSecurityException
start query parameter missing, or end <= startIllegalArgumentException
Unknown collection pathIllegalArgumentException

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>
ParameterRequiredMeaning
startYesInclusive start of the window, epoch milliseconds (UTC).
endNoExclusive end of the window, epoch milliseconds (UTC). Defaults to now.
groupNoBatch-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.

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.

Match semantics

Ordering

Results are ordered ascending by start_ms (timeline) / timestamp_ms (samples).

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> — though ContentResolver.query never requires you to read it.

visits

ColumnTypeNotes
_idlongStable visit id.
start_mslongStay start.
end_mslongStay end (equals "now" while the visit is ongoing).
place_idlong?Matched place id, or null when unconfirmed / candidate-only.
place_namestring?Matched place name, else candidate name, else null.
latitudedoubleVisit centroid latitude.
longitudedoubleVisit centroid longitude.
radius_metersdoubleApproximate radius of the stay.
confidencefloatPlace-attribution confidence, [0,1].
confirmedint1 if the user confirmed the place, else 0.
is_ongoingint1 if this is the current open visit, else 0.

trips

ColumnTypeNotes
_idlongStable trip id.
start_mslongMovement start.
end_mslongMovement end.
modestringOne of the transport modes below.
mode_confidencefloatConfidence of mode, [0,1].
distance_metersdoubleTotal distance traveled.
encoded_polylinestring?Route as a Google encoded polyline (precision 5). null unless the caller holds READ_TIMELINE_ROUTE or READ_LOCATION_HISTORY (§4).
confirmedint1 if the user confirmed the mode, else 0.
from_visit_idlong?Departure visit id, or null.
to_visit_idlong?Arrival visit id, or null.

samples

ColumnTypeNotes
_idlongStable sample id.
timestamp_mslongTime of the fix.
latitudedouble
longitudedouble
altitudedouble?Meters, or null.
accuracyfloat?Horizontal accuracy radius in meters, or null.
bearingfloat?Degrees, or null.
speedfloat?Meters/second, or null.
providerstring?Source provider (e.g. fused, gps), or null.
is_mockint1 if reported as a mock location, else 0.
device_statestringOne of the device states below.
ar_activitystring?Raw Activity-Recognition activity string, or null.
network_transportstring?One of the network transports below, or null.
included_in_computationint1 if used in timeline computation, 0 if excluded (e.g. mock / GPS drift).

Enumerations

String columns use fixed, stable token sets. Treat any unrecognized value as the UNKNOWN/null case.

ColumnValues
trips.modeWALKING, RUNNING, CYCLING, CAR, BUS, RAIL, FERRY, FLIGHT, UNKNOWN
samples.device_stateSTATIONARY, WALKING, RUNNING, CYCLING, IN_VEHICLE, UNKNOWN
samples.network_transportWIFI, CELLULAR, ETHERNET, VPN, OTHER, NONE

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
}

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."

Notification behavior

Pathline proactively alerts the user about third-party access. Assume both of the following will surface as user notifications:

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 1 and will not change without a new API revision:

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

SymptomLikely cause / fix
query returns nullPathline not installed, or missing <queries> visibility (§2).
SecurityException on every callMissing the install-time …permission.API gate — declare it with <uses-permission> (§4).
SecurityExceptionMissing base permission, or an over-30-day window without READ_EXTENDED_HISTORY (§4).
IllegalArgumentExceptionMissing start, end <= start, or an unknown path (§4).
No permission dialog appearsOEM skin suppressing third-party custom-permission prompts — deep-link to app settings (§4).
Empty results for a valid windowPermission granted but the user simply has no data in that window, or all samples were excluded.
encoded_polyline is always nullCaller holds READ_TIMELINE but neither READ_TIMELINE_ROUTE nor READ_LOCATION_HISTORY (§4).
Observer never firesExpected — 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 1)

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)
        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
    }

    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"
    }
}