Developer guide
Integrate the SDK
Native SDKs for Android and iOS, with bridges for React Native and Flutter. Pick your platform - the setup genuinely differs, so these are not one page.
FieldTrack SDK — Integration Guide
<p align="center"> <a href="https://jitpack.io/#fieldtrack360/fieldtrack"><img src="https://jitpack.io/v/fieldtrack360/fieldtrack.svg" alt="JitPack version"/></a> </p>Background location tracking and track plotting for Android. This document is the complete public reference for an app integrating the SDK: install, setup, every configuration option, every public method, every event and callback.
Android only. Kotlin-first, Java-callable.
| Maven group | com.github.fieldtrack360.fieldtrack |
| Distribution | JitPack (https://jitpack.io) |
minSdk | 26 (Android 8.0) |
compileSdk / targetSdk | 36 |
| Java bytecode | 11 — loads on any JDK 11+ toolchain |
| Kotlin | 2.1.x |
| Host baseline | AGP 8.x · Kotlin 2.0+ · React Native 0.81+ compatible |
Table of contents
- Install
- License token
- Quick start
- Permissions
- Configuration reference
- Public API —
Tracker - Events, state and callbacks
- Data models
- Plotting and export
- Live tracking
- Geofences
- Battery and sensors
- Maps module
- Sync module — upload to your backend
- Snap module — road matching
- Diagnostics
- Java interop
- ProGuard / R8
- Device integrity
- Troubleshooting
1. Install
1.1 Add the JitPack repository
JitPack must be declared where your project resolves dependencies.
Gradle 7+ / settings.gradle.kts (recommended):
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
Groovy settings.gradle:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
Older projects (build.gradle at root):
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
1.2 Add the dependency
The umbrella artifact pulls the whole SDK in transitively. Replace <version> with the
release tag you want (for example 0.1.1-alpha01).
// app/build.gradle
dependencies {
implementation 'com.github.fieldtrack360.fieldtrack:fieldtrack:<version>'
}
// app/build.gradle.kts
dependencies {
implementation("com.github.fieldtrack360.fieldtrack:fieldtrack:<version>")
}
With a version catalog:
# gradle/libs.versions.toml
[versions]
= "<version>"
[libraries]
= { group = "com.github.fieldtrack360.fieldtrack", name = "fieldtrack", version.ref = "fieldtrack" }
dependencies {
implementation(libs.fieldtrack)
}
1.3 Retrofit and OkHttp are compileOnly in the optional modules
fieldtrack-sync and fieldtrack-snap declare both as compileOnly, so neither is
pulled into your app. If you use their built-in HTTP paths, add them yourself:
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.okhttp3:okhttp:5.1.0")
Retrofit 3 requires OkHttp 5 — they are versioned together, not independently.
You can skip both entirely by supplying your own SyncTransport (see
§14.6) or your own RoadSnapProvider (see
§15).
fieldtrack-core is different. It links Retrofit, OkHttp, Gson and Tink as real
dependencies, because the licence check has to work in a host that brought no HTTP client
of its own. There is no opt-out, deliberately: a licensing layer an integrator could
disable by omitting a dependency would not be a licensing layer.
1.4 What you do not have to add
No DI framework. No Hilt, no
@HiltAndroidApp, no KSP, no Gradle plugin. The SDK's object graph is wired internally.No manifest entries. The AAR declares every permission, the foreground service and all three broadcast receivers; they merge into your APK automatically. Merged in:
ACCESS_COARSE_LOCATION,ACCESS_FINE_LOCATION,ACCESS_BACKGROUND_LOCATION,FOREGROUND_SERVICE,FOREGROUND_SERVICE_LOCATION,POST_NOTIFICATIONS,ACTIVITY_RECOGNITION(+ the Google Play Services variant),RECEIVE_BOOT_COMPLETED,WAKE_LOCK,ACCESS_NETWORK_STATE.REQUEST_IGNORE_BATTERY_OPTIMIZATIONSis deliberately not declared — it is Play-policy sensitive and must be your own explicit choice.No ProGuard rules.
consumer-rules.proships inside each AAR.
1.5 Google Play Services
The default provider (LocationProviderType.FUSED) needs Google Play Services. For devices
without it (Huawei, AOSP builds), use LocationProviderType.GPS_ONLY, NETWORK_ONLY or
PASSIVE — these run on the platform LocationManager and need nothing from Google. See
§5.2.
1.6 Toolchain compatibility
The published AARs are deliberately built against a conservative baseline so that mainstream host toolchains — including React Native 0.81+ Android hosts — can consume them without upgrading anything:
| The AARs ship | Your project needs |
|---|---|
| Kotlin 2.1.x metadata | Kotlin 2.0 or newer |
compileSdk 36 | compileSdk 36 or newer |
| Java 11 bytecode | Any JDK 11+ toolchain (JDK 17 recommended) |
| AGP 8.x metadata | AGP 8.x or newer |
play-services-location 21.3.x and (for fieldtrack-maps) play-services-maps 19.2.x
arrive transitively; declaring a newer version in your own app wins through normal Gradle
conflict resolution.
2. License token
Release builds require a license token. Debuggable builds are waived automatically — you can develop with no token at all.
New to how this works?
how-the-local-licence-works.mdexplains the offline check in plain English, with no prior knowledge assumed.
tracker.ready(
TrackerConfig.builder()
.license(BuildConfig.FIELDTRACK_LICENSE)
.build()
)
Keep the token out of source control. local.properties is gitignored and already carries
the sample's Maps key:
# local.properties
=TRACKIT-eyJ2IjoxLCJraWQiOjEs…
// your app's build.gradle.kts
val localProperties = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }
?.inputStream()?.use { load(it) }
}
buildConfigField(
"String", "FIELDTRACK_LICENSE",
"\"${localProperties.getProperty("FIELDTRACK_LICENSE", "")}\"",
)
This keeps the token out of the repository, not out of the APK — it is compiled into
BuildConfig and readable by anyone who unzips your build. That is expected: the token is
bound to your application id and signed, so a copy of it is worth nothing in another app.
It is still worth what you paid, so do not commit it.
The token is bound to your application id. ready() returns a TrackerResult.Error with
LICENSE_MISSING, LICENSE_INVALID or LICENSE_BUNDLE_MISMATCH when the offline check
fails, and the same failure is emitted on the event flow as TrackerEvent.Error.
The license field is never persisted with the rest of the config — it is re-read from
config on every ready(), so "I updated my licence" never turns into a stale token
resurrected from disk.
The online check
Beyond the offline gate, the SDK asks the licence server whether the token has been revoked or expired since it was issued. You do not wire anything up for this.
| When | Shortly after every ready(), and every 12 hours while installed |
| Blocking? | No. ready() decides from a cached verdict and returns; the call runs unawaited |
| Offline? | Carries on. Fail-open by design — a server outage never stops a paying customer |
| You see | TrackerEvent.LicenseChecked, tracker.licenseInfo(), tracker.checkLicense() |
tracker.events
.filterIsInstance<TrackerEvent.LicenseChecked>()
.onEach { Log.i("licence", "${it.info.status} cached=${it.info.fromCache}") }
.launchIn(scope)
// any time, no network cost:
when (tracker.licenseInfo()?.status) {
LicenseStatus.ACTIVE -> Unit
null -> Unit // not checked yet — NOT a refusal
else -> showLicenceBanner()
}
Silence is not success. No event is emitted when the network failed or the response
could not be verified — all of those carry on tracking and report nothing, because reading
silence as approval would mean reading a server outage as a valid licence. A successful
ready() likewise means "no cached verdict said stop", not "the licence was just checked".
| Member | Type | Meaning |
|---|---|---|
status | LicenseStatus | ACTIVE, REVOKED, EXPIRED, UNKNOWN_KEY, INVALID_KEY, PACKAGE_MISMATCH, SDK_MISMATCH, UNRECOGNISED |
valid | Boolean | The server's own flag. Branch on status, not this |
packageName | String | The application id the licence was issued against |
checkedAt | String | ISO-8601, the server's clock, verbatim |
ttlSeconds | Long | How long this answer may keep being trusted |
reason | String? | The server's explanation, when it sent one |
fromCache | Boolean | true for a stored verdict. Re-verified on read, so no less trustworthy |
Only REVOKED and EXPIRED stop tracking. The rest are diagnostics — UNKNOWN_KEY and
INVALID_KEY mean the token verified offline against a key we compiled in ourselves and
the backend had no matching record, which is our ledger being wrong, not your licence.
3. Quick start
Three calls: getInstance → ready → start.
class MyApplication : Application() {
val tracker: Tracker by lazy { Tracker.getInstance(this) }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
scope.launch {
when (val result = tracker.ready(TrackerConfig())) {
is TrackerResult.Ok -> Log.d("app", "ready: ${result.value}")
is TrackerResult.Error -> Log.w("app", "${result.code}: ${result.message}")
}
}
}
}
// After permissions are granted:
suspend fun begin() {
when (val result = tracker.start(tag = "commute")) {
is TrackerResult.Ok -> Log.d("app", "session ${result.value.id}")
is TrackerResult.Error -> Log.w("app", "${result.code}: ${result.message}")
}
}
suspend fun end() {
tracker.stop()
}
Read the data back:
val points = tracker.getPoints(PointQuery(sessionId = sessionId))
val track = tracker.buildTrack(PointQuery(sessionId = sessionId))
val json = tracker.exportPolylineJson(PointQuery(sessionId = sessionId))
val distance = tracker.getOdometerMeters()
Contract notes
Tracker.getInstance(context)is idempotent and thread-safe — one instance per process. It retains only the application context, so passing anActivityleaks nothing. It is cheap: no database is opened and no disk touched untilready().- Nothing on the
Trackersurface throws. Every fallible call returnsTrackerResultwith a typedErrorCode. The only deliberate exceptions areTrackerConfig.Builder.build()andSyncConfig.Builder.build(), which fail fast withIllegalArgumentExceptionon your own thread while you assemble the value. UsebuildUnchecked()+validate()if you prefer to read the errors yourself. ready()must be called beforestart()orgetCurrentLocation(); otherwise you getErrorCode.NOT_READY.
4. Permissions
The SDK shows no UI. No dialogs, no activities, no full-screen intents. It answers questions and hands you the permission arrays and the Settings intent; your app owns every prompt.
val permissions: PermissionManager = tracker.permissions()
4.1 PermissionManager API
| Method | Returns | Notes |
|---|---|---|
tier() | PermissionTier | NONE, FOREGROUND_ONLY or FULL |
accuracy() | LocationAccuracy | PRECISE when ACCESS_FINE_LOCATION is granted, else APPROXIMATE |
hasActivityRecognition() | Boolean | Always true below API 29 |
hasNotificationPermission() | Boolean | Always true below API 33 |
foregroundPermissions() | Array<String> | Step 1 — fine + coarse together |
notificationPermissions() | Array<String> | Ask first on API 33+; empty below |
activityRecognitionPermissions() | Array<String> | Optional; empty below API 29 |
backgroundRequest() | BackgroundRequest | Step 2 — what to do next for FULL |
shouldStopAsking(attempts: Int) | Boolean | true at 3 attempts — stop prompt-looping |
appSettingsIntent() | Intent | Deep link to your app's settings page |
tracker.permissionTier() is a shortcut for permissions().tier().
4.2 The ladder
PermissionManager.BackgroundRequest is a sealed interface:
| Case | Meaning |
|---|---|
AlreadyGranted | Nothing to do |
NotApplicable | Below API 29 — no separate background permission exists |
NeedsForegroundFirst | Ask for fine location first; asking for background before it is a silent denial |
Prompt(permissions) | API 29 only — a runtime prompt still works |
NeedsSettings(intent) | API 30+ — the OS shows no prompt; deep-link to Settings and explain "Allow all the time" |
// Step 0 — API 33+: notifications, before starting a foreground service
launcher.launch(permissions.notificationPermissions())
// Step 1 — foreground location (fine + coarse in ONE request)
launcher.launch(permissions.foregroundPermissions())
// Step 2 — background, only after fine is granted and you have shown a rationale
when (val request = permissions.backgroundRequest()) {
is PermissionManager.BackgroundRequest.Prompt -> launcher.launch(request.permissions)
is PermissionManager.BackgroundRequest.NeedsSettings -> startActivity(request.intent)
PermissionManager.BackgroundRequest.NeedsForegroundFirst -> askForegroundFirst()
PermissionManager.BackgroundRequest.AlreadyGranted,
PermissionManager.BackgroundRequest.NotApplicable -> Unit
}
// Optional — activity recognition. Denial degrades motion detection; never fatal.
launcher.launch(permissions.activityRecognitionPermissions())
4.3 Tier behaviour
NONE—start()andgetCurrentLocation()returnErrorCode.PERMISSION_DENIED.FOREGROUND_ONLY— tracking runs while your app is in the foreground. Background location is not a hard gate; you still get data.FULL— background tracking works.
accuracy() is orthogonal and always surfaced. A 1–3 km error circle defeats every gate in
the pipeline, so CONTINUOUS/ADAPTIVE refuse to start on approximate-only access.
5. Configuration reference
TrackerConfig is a data class with five nested blocks. Kotlin hosts can use named
arguments and copy(); Java hosts (and anyone who prefers fluency) use TrackerConfig.builder().
val config = TrackerConfig.builder()
.provider(LocationProviderType.FUSED)
.accuracyProfile(AccuracyProfile.STRICT)
.intervalMs(30_000)
.notification("Delivery in progress", "Recording your route")
.baseUrl("https://api.example.com")
.build() // validates; throws IllegalArgumentException on failure
tracker.ready(config)
5.1 Top-level TrackerConfig
| Field | Type | Default | What it does |
|---|---|---|---|
geolocation | GeolocationConfig | defaults | Provider, accuracy, cadence |
motion | MotionConfig | defaults | Activity recognition, stop detection, heartbeat |
service | ServiceConfig | defaults | Foreground service, notification, survival |
persistence | PersistenceConfig | defaults | Retention and diagnostic storage |
sensors | SensorConfig | defaults | Hardware motion assists |
security | SecurityConfig | defaults | Device-integrity policy — accessibility, developer mode, hooking frameworks, clock tampering, mock-location apps (§19). Waived entirely in debuggable builds |
license | String? | null | Release license token. Never persisted |
baseUrl | String? | null | Scheme + host for uploads, e.g. https://api.example.com. Core never opens a socket; fieldtrack-sync resolves a relative path against it |
reset | Boolean | true | true — this config is applied on top of factory defaults. false — the persisted config wins and this object is ignored after the first launch; only setConfig() changes anything after that. Leave true during development |
Builder methods for the whole blocks: .geolocation(), .motion(), .service(),
.persistence(), .sensors(), .security(), .license(), .baseUrl(), .reset().
config.validate(): List<String> returns everything wrong with a config, or an empty list.
ready() runs it and returns ErrorCode.INVALID_CONFIG with the joined messages.
5.2 GeolocationConfig
| Field | Type | Default | What it does |
|---|---|---|---|
trackingMode | TrackingMode | ADAPTIVE | See below. Can be overridden at ready() on hardware that cannot detect motion — §12.1 |
providerType | LocationProviderType | FUSED | Which hardware produces fixes |
desiredAccuracy | DesiredAccuracy | HIGH | Biases the fused provider's own source choice. HIGH, BALANCED, LOW |
accuracy | AccuracyConfig | BALANCED profile | The accuracy meter — see §5.6 |
distanceFilterM | Float | 0f | Must stay 0. A non-zero OS distance filter generates stationary drift; all thinning is done in software |
intervalMs | Long | 60_000 | Requested sampling interval |
fastestIntervalMs | Long | 30_000 | Fastest the OS may deliver. Must be ≤ intervalMs |
maxUpdateDelayMs | Long | 60_000 | OS batching window |
maxFixAgeMs | Long | 10_000 | Older fixes are treated as stale |
deliveryStalenessMs | Long | 60_000 | Delivery-gap threshold |
adaptiveCadence | Boolean | true | Speed up while vehicular. Off pins every moving fix to intervalMs |
vehicularIntervalMs | Long | 12_000 | The vehicular tier interval. Raised by measured speed (≥ 2.5 m/s ≈ 9 km/h), not by the motion state — a walking session stays on intervalMs |
turnBurst | Boolean | true | Third tier: sample faster while measurably turning |
turnBurstIntervalMs | Long | 4_000 | Must be > 0 and ≤ the tier it accelerates |
oneShotTimeoutMs | Long | 30_000 | getCurrentLocation() timeout |
mockLocationPolicy | MockPolicy | FLAG | FLAG (store + mark), REJECT, ALLOW |
navigationMode | Boolean | false | ~1 Hz high-accuracy profile that overrides every adaptive tier. Requires service.foregroundService |
navigationIntervalMs | Long | 1_000 | Navigation interval |
navigationFastestIntervalMs | Long | 500 | Navigation floor |
TrackingMode
| Value | Behaviour |
|---|---|
CONTINUOUS | Stream at intervalMs always; the filter does all thinning. Highest fidelity, highest battery |
ADAPTIVE | Stream while moving with adaptive cadence; heartbeat-only while stationary (default) |
MOTION_ONLY | Location fully off while stationary. Lowest battery, coarsest stop timing |
When each tier is in force (fastest wins: navigation → turn burst → vehicular → base):
| Tier | Raised by | Dropped by |
|---|---|---|
| vehicular | A fix reporting ≥ 2.5 m/s — the same threshold the turn burst and the gyroscope use. Not the MOVING motion state: walking is moving and is not vehicular | STOP_PENDING — the moment fixes stop reporting movement, not stopTimeoutMin later |
| turn burst | TurnDetector (GNSS heading) or GyroTurnMonitor (yaw rate), whichever sees the corner first | Both releasing it, or a stop |
A tier dropped at STOP_PENDING is parked, not forgotten: pulling away restores it on the
MOVING transition rather than waiting for another fix to measure speed. A committed stop
(STATIONARY) clears the claim, so whatever moves next earns the tier from speed again.
The battery difference between the three modes is mostly not the interval — it is whether
the location stream stays registered while the device is parked. ADAPTIVE keeps it registered
and lets the filter thin, because the heartbeat is what recovers a device whose wake paths
all failed; MOTION_ONLY genuinely unregisters it. A phone parked for eight hours costs
near zero on the third and a full day of radio duty on the first two.
The mode you set is not always the mode that runs. On a device where motion detection
cannot be trusted, ready() rewrites this to CONTINUOUS — which is the most expensive
mode short of navigationMode, so a host that chose MOTION_ONLY for battery gets the
opposite. Read TrackerState.effectiveTrackingMode for what is actually in force, and
§12.1 for when and why.
LocationProviderType
| Value | Behaviour |
|---|---|
FUSED | Play Services fused provider. Blends GNSS, Wi-Fi, cell and sensors. Best time-to-first-fix. Default |
GPS_ONLY | LocationManager.GPS_PROVIDER. Satellite-only — no Wi-Fi teleports, but no fix indoors/tunnels, 30–60 s cold starts, more battery. Works without Play Services |
NETWORK_ONLY | Wi-Fi/cell centroids. Coarse (20–2000 m), cheap. Needs an accuracy ceiling ≥ 50 m — validate() rejects a tighter one |
PASSIVE | Fixes other apps requested, for free. No power cost, no guarantee of any data. Every cadence tier is inert; navigationMode is refused |
Builder: .trackingMode(), .provider(), .desiredAccuracy(), .accuracy(),
.accuracyProfile(), .maxAccuracyMeters(), .recoveryTrustMeters(), .intervalMs(),
.fastestIntervalMs(), .maxUpdateDelayMs(), .maxFixAgeMs(), .adaptiveCadence(),
.vehicularIntervalMs(), .turnBurst(), .turnBurstIntervalMs(), .navigationMode(),
.navigationIntervalMs(), .navigationFastestIntervalMs(), .oneShotTimeoutMs(),
.mockLocationPolicy().
5.3 MotionConfig
| Field | Type | Default | What it does |
|---|---|---|---|
activityRecognition | Boolean | true | Use Play Services activity recognition as enrichment |
activityRecognitionIntervalMs | Long | 10_000 | AR polling interval |
activityConfidenceMin | Int | 75 | Minimum confidence for a transition |
snapshotConfidenceMin | Int | 50 | Minimum confidence for a snapshot read |
disableStopDetection | Boolean | false | Never enter the stationary state |
stopOnStationary | Boolean | false | End the session automatically when stationary |
stopTimeoutMin | Int | 5 | Minutes of no movement before STATIONARY |
stationaryRadiusM | Float | 150f | Radius of the internal stationary wake geofence. Must be > 0 |
stationaryGeofenceId | String | "trackit-stationary" | Id of that fence. Must not be blank |
stationaryGeofenceOnEnterEvent | String | "stationary_fence_enter" | Event name on enter |
stationaryGeofenceOnExitEvent | String | "stationary_fence_exit" | Event name on exit |
motionTriggerDelayMs | Long | 0 | Delay before acting on a motion trigger |
heartbeatIntervalSec | Int | 900 | Data-plane heartbeat: warms the filter, stores nothing. This is what makes a two-hour steady user produce exactly one point. Must be ≥ 5 × the sampling interval |
persistHeartbeat | Boolean | false | Also store the heartbeat point |
bearingChangeCaptureDeg | Int | 30 | Store a point whenever heading turned this far since the last stored one, regardless of speed/distance gates. 0 disables. 30° sits below a motorway interchange and above lane-change/GPS heading noise. It was 40, which is a junction threshold rather than a bend threshold — a long curve turning 35° between stored points never crossed it, so the track kept the straight legs and dropped the curve |
cornerAnchorCapture | Boolean | true | Restore a rejected fix once the next fix shows a corner turned across it. Bearing-change capture compares against the last stored point, so at a corner's apex only half the turn is behind you and the apex is dropped; this holds the rejection for one fix and keeps it if the path bent across it (Reasons.CORNER_ANCHOR). Only the heuristic gate's rejections are reconsidered — never impossible speed, poor accuracy or the sigma gate. One fix of latency, and only for fixes that were being discarded |
Builder: .activityRecognition(), .activityRecognitionIntervalMs(), .activityConfidenceMin(),
.snapshotConfidenceMin(), .disableStopDetection(), .stopOnStationary(), .stopTimeoutMin(),
.stationaryRadiusM(), .stationaryGeofenceId(), .stationaryGeofenceOnEnterEvent(),
.stationaryGeofenceOnExitEvent(), .motionTriggerDelayMs(), .heartbeatIntervalSec(),
.persistHeartbeat(), .bearingChangeCaptureDeg(), .cornerAnchorCapture().
5.4 SensorConfig
| Field | Type | Default | What it does |
|---|---|---|---|
useSignificantMotion | Boolean | true | Permission-free, ~zero-power hardware wake for STATIONARY → MOVING |
useStepCorroboration | Boolean | true | Step-count veto on stationary drift; confirms indoor walks |
useAccelerometerVeto | Boolean | true | Reject "movement" with no accelerometer support |
useBarometer | Boolean | false | Use pressure sensor when present |
stepBatchLatencyMs | Long | 60_000 | Step-counter batching latency |
useGyroTurnPrediction | Boolean | true | Arm the turn burst from gyroscope yaw rate, ahead of GNSS heading. Needs no permission. The gyroscope is opened only while fixes report vehicular speed and released within a minute of them stopping, so a walking or parked session never touches it. No-op when geolocation.turnBurst is off or the device has no gyroscope |
Builder: .useSignificantMotion(), .useStepCorroboration(), .useAccelerometerVeto(),
.useGyroTurnPrediction(),
.useBarometer(), .stepBatchLatencyMs().
5.5 ServiceConfig
| Field | Type | Default | What it does |
|---|---|---|---|
foregroundService | Boolean | true | Run capture in a foreground service |
stopOnTerminate | Boolean | false | Inverted from the common default on purpose — a swipe-away does not silently end tracking |
startOnBoot | Boolean | true | Resume an open session after reboot / app update |
healthLoopMs | Long | 120_000 | Supervision loop period |
watchdogIntervalMs | Long | 60_000 | Watchdog check period |
watchdogThrottleMs | Long | 900_000 | Minimum gap between watchdog restarts |
backstopIntervalMin | Int | 15 | WorkManager backstop period |
deadTrackerMovingMin | Int | 30 | Minutes with no fix while moving before declaring the tracker dead |
deadTrackerStationaryMin | Int | 60 | Same, while stationary |
wakeLockMs | Long | 20_000 | Wake-lock hold during a capture burst |
notificationTitle | String | "Tracking active" | Foreground notification title. Never overridden — the sync diagnostic below cannot take this slot |
notificationText | String | "Recording your location" | Foreground notification body |
notificationChannelId | String | "trackit_tracking" | Channel id |
notificationChannelName | String | "Location tracking" | Channel name shown in system settings |
notificationSmallIconResName | String? | null | Drawable resource name (e.g. "ic_tracking") for the small icon |
showSyncStatusInNotification | Boolean | false | Diagnostic — leave off in a shipping app. Layers a live upload-queue line onto the subtitle and body while tracking; the title is untouched. See below |
syncNotificationSubText | String? | null | The subtitle shown beside notificationTitle while the sync line is on screen. null = no subtitle. Rendered with setSubText; never replaces the title |
syncNotificationText | String | "unsynced {pending} · last upload {age}" | The sync line template. See the token table below. Ignored unless showSyncStatusInNotification is on |
Builder: .foregroundService(), .stopOnTerminate(), .startOnBoot(), .healthLoopMs(),
.watchdogIntervalMs(), .watchdogThrottleMs(), .backstopIntervalMin(),
.deadTrackerMovingMin(), .deadTrackerStationaryMin(), .wakeLockMs(),
.notification(title, text), .notificationChannel(id, name), .notificationSmallIconResName(),
.showSyncStatusInNotification(), .syncNotification(subText, text).
The upload-status notification
Off by default, and it should stay off in a shipping app. The ongoing notification is the one
piece of SDK surface a real user reads, and unsynced 42 means nothing to them while meaning
something alarming.
What it is for is the one test that cannot be run from inside the app: kill the host, take the device offline, wait, restore connectivity, and confirm the queue drains — without launching anything, because launching the app is itself a sync trigger and would invalidate the test. The notification is the only readout that survives that, and it needs no debugger, no adb and no server-side check.
It occupies the subtitle and the body, never the title. The notification has three text slots and the host keeps the one that matters:
┌──────────────────────────────────────┐
│ Tracking active · upload │ ← notificationTitle · syncNotificationSubText
│ unsynced 42 · last upload 21m ago │ ← syncNotificationText
└──────────────────────────────────────┘
With the diagnostic off — or on with no syncNotificationSubText set — there is no subtitle at
all and the body is notificationText. The title reads the same either way, because it names
the app holding the foreground service and a debug readout must not take that line.
syncNotificationText is substituted at post time:
| Token | Becomes |
|---|---|
{pending} | Rows queued and not yet uploaded, e.g. 42 |
{age} | Time since the last confirmed upload, e.g. 21m ago, or never |
Both tokens are optional and may appear in any order — "{pending} to upload" is a valid
template, as is a static string with neither. An unrecognised {token} is left exactly as
written rather than blanked, so a typo shows up on the notification as itself instead of
silently vanishing.
Keep both defaults unless you have a reason not to. The count alone cannot tell a draining queue
from one that is merely not growing — a parked device stores nothing, so a still count is the
expected reading, not a stalled one. {age} is what separates them: it resets the moment
anything reaches the server.
Two more things to know before reading the number:
- It refreshes on the
watchdogIntervalMstick, so it lags reality by up to that long. A count that has not moved for one tick has not necessarily stalled. - The line only appears while sync is actually configured. With no
configure()call — or after a terminal401/403clears the config mid-session — the subtitle disappears and the body reverts to your ownnotificationText. The title never moved, so the notification simply looks as it did before the diagnostic was on. See §14.4.
5.6 AccuracyConfig
A ceiling on the reported error radius, applied to fixes claiming to be moving. Stationary fixes are deliberately governed by the anchor/wobble defences instead.
| Field | Type | Default | Notes |
|---|---|---|---|
profile | AccuracyProfile | BALANCED | Named ceiling |
maxAccuracyMeters | Float? | null | Required by CUSTOM, rejected by every other profile |
recoveryTrustMeters | Float? | null | Overrides the profile's post-gap re-anchor bar. Must be > 0 |
| Profile | Moving ceiling | Re-anchor bar | Use when |
|---|---|---|---|
STRICT | 20 m | 15 m | Urban canyon; sparser track, no zigzag |
BALANCED | 30 m | 25 m | Default, set from field data |
RELAXED | 60 m | 40 m | Indoor / network-assisted / coverage-first |
CUSTOM | maxAccuracyMeters (5–500 m) | 25 m unless overridden | You know your own bar |
Derived read-only properties: accuracy.maxAccuracyM and accuracy.recoveryTrustM (always
coerced to ≤ maxAccuracyM).
TrackerConfig.builder().accuracyProfile(AccuracyProfile.STRICT).build()
TrackerConfig.builder().maxAccuracyMeters(35f).build() // implies CUSTOM
5.7 PersistenceConfig
| Field | Type | Default | What it does |
|---|---|---|---|
maxDaysToPersist | Int | 7 | TTL for stored points. 0 = unlimited. Must be ≥ 0 |
maxRecords | Int | 0 | Row cap. 0 = unlimited |
persistRawFixes | Boolean | false | Store fixes exactly as the OS delivered them (debug layer 1) |
rawRingCapacity | Int | 5_000 | Ring size for raw fixes |
persistRawPoints | Boolean | false | Store every judged fix in point form, accepted or not (debug layer 2). One wide row per fix — real write amplification |
rawPointRingCapacity | Int | 20_000 | Rows kept per session, not globally |
persistDecisions | Boolean | true | Keep the decision log |
decisionRetentionDays | Int | 3 | Decision log TTL |
decisionMaxRows | Int | 50_000 | Decision log row cap |
Builder: .maxDaysToPersist(), .maxRecords(), .persistRawFixes(), .rawRingCapacity(),
.persistRawPoints(), .rawPointRingCapacity(), .persistDecisions(),
.decisionRetentionDays(), .decisionMaxRows().
5.8 Validation rules
validate() (and therefore build() / ready()) rejects:
intervalMs < fastestIntervalMsdistanceFilterM > 0heartbeatIntervalSec < 5 × (intervalMs / 1000)stationaryRadiusM <= 0, blankstationaryGeofenceId/ enter / exit event namesbaseUrlthat is not an absolute URL with a scheme and hostturnBurstIntervalMs <= 0, or greater than the tier it acceleratesnavigationIntervalMs <= 0,navigationIntervalMs < navigationFastestIntervalMs, ornavigationModewithoutforegroundServicemaxDaysToPersist < 0maxAccuracyMetersset withoutCUSTOM, missing withCUSTOM, or outside 5–500 mrecoveryTrustMeters <= 0NETWORK_ONLYwith an accuracy ceiling below 50 mnavigationModewithproviderType = PASSIVE- blank
syncNotificationTextwhileshowSyncStatusInNotificationis on — checked only when the line will actually be posted, so leaving the diagnostic off is never refused over a string nothing reads syncNotificationSubTextset to a blank string — usenullfor no subtitle
6. Public API — Tracker
val tracker = Tracker.getInstance(context) // @JvmStatic, idempotent, thread-safe
6.1 Lifecycle
| Method | Signature | Notes |
|---|---|---|
ready | suspend fun ready(config: TrackerConfig = TrackerConfig()): TrackerResult<TrackerState> | Verifies the license, resolves and validates config, restores persisted filter state, starts provider/battery monitoring, enqueues the daily prune, and emits SessionInterrupted if a session was left open by a crash or force-stop |
start | suspend fun start(tag: String? = null): TrackerResult<TrackSession> | Opens a session. NOT_READY if ready() was not called |
stop | suspend fun stop(): TrackerResult<TrackSession?> | Closes the open session |
state | val state: StateFlow<TrackerState> | Coarse lifecycle state |
events | val events: SharedFlow<TrackerEvent> | Replay 0, unlimited subscribers |
6.2 Location
| Method | Signature | Notes |
|---|---|---|
getCurrentLocation | suspend fun getCurrentLocation(): TrackerResult<TrackFix> | One fresh fix. Snapshot only — not accepted, persisted, added to the odometer, or emitted as a tracking location. Errors: NOT_READY, PERMISSION_DENIED, LOCATION_DISABLED, FIX_TIMEOUT |
providerState | fun providerState(): StateFlow<ProviderState> | GPS toggle, permission tier, granularity, fused availability, battery saver. Broadcast-driven, never polled |
permissionTier | fun permissionTier(): PermissionTier | |
permissions | fun permissions(): PermissionManager | The permission ladder as data |
offerFix | fun offerFix(fix: TrackFix) | Feed a fix from a source the SDK does not own (a test, a replay, a custom provider). It is judged by exactly the same gates — you cannot inject an unvalidated point |
6.3 Reading data
| Method | Signature |
|---|---|
getPoints | suspend fun getPoints(query: PointQuery = PointQuery()): List<TrackPoint> |
observePoints | fun observePoints(sessionId: String): Flow<List<TrackPoint>> |
getCount | suspend fun getCount(query: PointQuery = PointQuery()): Int |
getOdometerMeters | suspend fun getOdometerMeters(): Double |
getSessions | suspend fun getSessions(fromMs: Long? = null, toMs: Long? = null): List<TrackSession> |
currentSession | suspend fun currentSession(): TrackSession? |
All reads are paged — PointQuery(limit = 500, offset = 0) by default.
6.4 Plotting
| Method | Signature |
|---|---|
buildTrack | suspend fun buildTrack(query: PointQuery = PointQuery(), options: TrackOptions = TrackOptions()): Track |
exportPolylineJson | suspend fun exportPolylineJson(query, options): String |
exportGeoJson | suspend fun exportGeoJson(query, options): String |
setRoadSnapProvider | fun setRoadSnapProvider(provider: RoadSnapProvider) |
6.5 Live tracking
| Method | Signature |
|---|---|
liveTrack | fun liveTrack(): Flow<LiveTrackUpdate> |
setActiveRoute | fun setActiveRoute(route: List<GeoPoint>) |
isOffRoute | fun isOffRoute(): Boolean |
6.6 Geofences
| Method | Signature |
|---|---|
addGeofence | suspend fun addGeofence(geofence: TrackerGeofence): TrackerResult<TrackerGeofence> |
removeGeofence | suspend fun removeGeofence(id: String = TrackerGeofence.DEFAULT_ID): TrackerResult<Boolean> |
removeAllGeofences | suspend fun removeAllGeofences(): TrackerResult<Int> |
getGeofence | fun getGeofence(id: String = TrackerGeofence.DEFAULT_ID): TrackerGeofence? |
getGeofences | fun getGeofences(): List<TrackerGeofence> |
getGeofenceEvents | fun getGeofenceEvents(geofenceId: String? = null, fromMs: Long? = null, toMs: Long? = null, limit: Int = 500, offset: Int = 0): List<TrackerGeofenceEvent> |
deleteGeofenceEvents | fun deleteGeofenceEvents(geofenceId: String? = null, fromMs: Long? = null, toMs: Long? = null): Int |
6.7 Device state
| Method | Signature |
|---|---|
batteryInfo | fun batteryInfo(): BatteryInfo |
batteryState | fun batteryState(): StateFlow<BatteryInfo> |
getSensors | fun getSensors(): DeviceSensors |
6.8 Diagnostics
| Method | Signature |
|---|---|
getRawFixes | suspend fun getRawFixes(sessionId: String): List<RawFix> |
getRawPoints | suspend fun getRawPoints(sessionId: String): List<RawPoint> |
getDecisions | suspend fun getDecisions(sessionId: String? = null, limit: Int = 200, offset: Int = 0): List<FixDecision> |
7. Events, state and callbacks
The SDK has no var callback properties — a second registrant would silently replace the
first. Everything is a Kotlin Flow.
7.1 TrackerEvent — the event flow
lifecycleScope.launch {
tracker.events.collect { event ->
when (event) {
is TrackerEvent.Location -> draw(event.point)
is TrackerEvent.LocationRejected -> log(event.decision)
is TrackerEvent.MotionChange -> updateUi(event.state, event.point)
is TrackerEvent.ActivityChange -> show(event.activity, event.confidence)
is TrackerEvent.EnabledChange -> toggle(event.enabled)
is TrackerEvent.ProviderChange -> render(event.state)
is TrackerEvent.Heartbeat -> touch(event.atMs)
is TrackerEvent.PowerSaveChange -> warn(event.enabled)
is TrackerEvent.BatteryChange -> battery(event.battery)
is TrackerEvent.GeofenceAdded -> Unit
is TrackerEvent.GeofenceRemoved -> Unit
is TrackerEvent.GeofenceEntered -> arrive(event.geofence)
is TrackerEvent.GeofenceExited -> depart(event.geofence)
is TrackerEvent.IntegrityChange -> integrity(event.report)
is TrackerEvent.SessionInterrupted -> offerResume(event.session)
is TrackerEvent.Diagnostic -> log(event.message)
is TrackerEvent.Error -> handle(event.code, event.message)
}
}
}
| Event | Payload | Fires when |
|---|---|---|
Location | point: TrackPoint | A fix was accepted and stored |
LocationRejected | decision: FixDecision | A fix was skipped or rejected, with the numeric reason |
MotionChange | state: MotionState, point: TrackPoint? | STOPPED ⇄ MOVING ⇄ STOP_PENDING ⇄ STATIONARY |
ActivityChange | activity: ActivityType, confidence: Int | Activity recognition transition |
EnabledChange | enabled: Boolean | Location services toggled |
ProviderChange | state: ProviderState | GPS toggle, permission change, granularity change, battery saver |
PermissionChange | previous: PermissionTier, current: PermissionTier, accuracy: LocationAccuracy | The location grant moved, in either direction — revoke, re-grant, all-the-time→while-using, precise→approximate |
LocationServicesChange | enabled: Boolean, state: ProviderState | The GPS/location master switch was toggled. Both directions, including the recovery |
CaptureSuspended | reason: ErrorCode, message: String | Capture stopped but the session is still open: permission revoked, or every provider off |
CaptureResumed | — | Capture re-armed in the same session after a CaptureSuspended |
Heartbeat | atMs: Long | Control-plane liveness tick (distinct from the data-plane heartbeat) |
PowerSaveChange | enabled: Boolean | Battery saver on/off |
BatteryChange | battery: BatteryInfo | Plug, unplug, low, okay — and drift the capture path notices |
GeofenceAdded / GeofenceRemoved | geofence / geofenceId | Registry changed |
GeofenceEntered / GeofenceExited | geofence: TrackerGeofence | A fence was crossed |
IntegrityChange | report: IntegrityReport | The device-integrity flag set changed — transitions only, not every check (§19) |
SessionInterrupted | session: TrackSession | ready() found a session left open by a crash or force-stop — you decide what to do |
Diagnostic | message: String | Informational |
Error | code: ErrorCode, message: String | Anything the SDK wants you to know about |
Collect from a lifecycle scope for UI, or from an application-scoped one for work that must continue with no UI on screen.
events has no replay. It is a SharedFlow with replay = 0, so an event emitted
while nothing is collecting is gone — it is a stream of things that happened, not a record
of the current state. That matters for one event in particular:
MOTION_DETECTION_DEGRADED is emitted inside ready(), and the documented startup
order has ready() in Application.onCreate with your collector created later in an
Activity or view model. If you follow that order you will never see it.
Nothing is lost, because the condition it reports is on the state flow instead — read
TrackerState.motionQuality (§7.2), which always has a current value
however late you subscribe. The general rule: conditions live on TrackerState,
transitions live on events. If you need an event that fires during ready(), start
collecting before you call it.
7.2 TrackerState
data class TrackerState(
val isReady: Boolean = false,
val isTracking: Boolean = false,
val isCapturing: Boolean = false, // false while isTracking = suspended
val motionState: MotionState = MotionState.STOPPED,
val providerState: ProviderState = ProviderState(),
val currentSessionId: String? = null,
val motionQuality: MotionQuality = MotionQuality.FULL,
val effectiveTrackingMode: TrackingMode = TrackingMode.ADAPTIVE,
)
| Field | Read it for |
|---|---|
isTracking vs isCapturing | A session with a revoked permission or a switched-off GPS stays open with isTracking = true and stops capturing. false while tracking means suspended, and the reason is on TrackerEvent.CaptureSuspended |
motionQuality | Whether this device's motion hardware can support the mode you asked for. The reliable read — the event that reports it fires during ready() and is easily missed (§12.1) |
effectiveTrackingMode | The mode actually in force, which differs from the configured one when the SDK overrode it. Nothing else exposes the resolved config |
Both motionQuality and effectiveTrackingMode are set by ready() and do not change
until the next ready() call.
7.3 ProviderState
data class ProviderState(
val gpsEnabled: Boolean = false,
val networkEnabled: Boolean = false,
val locationServicesEnabled: Boolean = false, // the Settings master switch
val permission: PermissionTier = PermissionTier.NONE,
val accuracyAuthorization: LocationAccuracy = LocationAccuracy.APPROXIMATE,
val fusedAvailable: Boolean = false,
val powerSaveMode: Boolean = false,
val airplaneMode: Boolean = false,
)
locationServicesEnabled is not the union of gpsEnabled and networkEnabled: a device
can report location enabled with GPS switched off, and the master switch is what a
"turn location on" prompt should be driven by.
airplaneMode is a diagnostic, never a gate — GPS keeps working in airplane mode on most
devices while network positioning does not, so it explains a track that degrades to GPS-only
or stops indoors rather than justifying a refusal to start.
Both are emitted on change through TrackerEvent.ProviderChange, like the rest of this
object; there is no polling.
7.4 TrackerResult and ErrorCode
sealed interface TrackerResult<out T> {
data class Ok<T>(val value: T) : TrackerResult<T>
data class Error(val code: ErrorCode, val message: String) : TrackerResult<Nothing>
}
ErrorCode | Meaning |
|---|---|
NOT_READY | ready() has not been called |
PERMISSION_DENIED | No location permission at all |
BACKGROUND_PERMISSION_MISSING | Background location needed for the requested behaviour |
COARSE_ONLY | Approximate-only access defeats the pipeline's gates |
LOCATION_DISABLED | GPS and network providers both off |
PLAY_SERVICES_UNAVAILABLE | Fused provider unavailable — switch to GPS_ONLY |
FGS_START_REFUSED | The OS refused the foreground service start |
NOTIFICATION_HIDDEN | The foreground notification is not visible |
FIX_TIMEOUT | No usable fix within oneShotTimeoutMs |
STORAGE_FULL | No room to persist |
STORAGE_RESET | The store had to be reset |
TRACKER_DEAD | No fix for deadTrackerMovingMin / deadTrackerStationaryMin |
INVALID_CONFIG | validate() reported errors |
LICENSE_MISSING / LICENSE_INVALID / LICENSE_BUNDLE_MISMATCH | Offline license gate, in ready() |
LICENSE_REVOKED / LICENSE_EXPIRED | Online check. Stops tracking |
LICENSE_UNKNOWN / LICENSE_PACKAGE_MISMATCH / LICENSE_SDK_MISMATCH | Online check. Diagnostic only — tracking continues |
NO_ACTIVITY | An Activity was required and none supplied |
MOTION_DETECTION_DEGRADED | motionQuality = POOR — motion gating is untrustworthy on this hardware |
GEOFENCE_REGISTRATION_FAILED / GEOFENCE_REMOVAL_FAILED / GEOFENCE_LIMIT_REACHED | Geofence operations |
SNAP_UNAVAILABLE | A RoadSnapProvider could not answer. Never fatal — the track is built from raw geometry with a snap_unavailable warning |
INTERNAL | Something threw where the contract says nothing throws. A bug in the SDK, not a condition to handle |
8. Data models
8.1 TrackPoint — an accepted, stored point
data class TrackPoint(
val id: Long = 0,
val uuid: String,
val sessionId: String,
val timeMs: Long, // wall clock, for display and day bucketing
val elapsedRealtimeNanos: Long, // monotonic, the real observation time
val localDate: String,
val timezone: String, // IANA id, stored PER POINT (a session can cross zones)
val latitude: Double,
val longitude: Double,
val accuracy: Float,
val altitude: Double? = null,
val speedMps: Float = 0f,
val bearingDeg: Float = 0f,
val hasSpeed: Boolean = false,
val hasBearing: Boolean = false,
val provider: String = "unknown",
val isMock: Boolean = false,
val movementStatus: MovementStatus = MovementStatus.STEADY,
val detectedActivity: ActivityType? = null,
val activityStartTimeMs: Long = 0,
val odometerMeters: Double = 0.0,
val batteryPct: Int? = null,
val isCharging: Boolean? = null,
val extras: String? = null,
val integrityFlags: Int = 0, // device-integrity bitmask at capture — see §19.4
val providerFlags: Int = 0, // location-subsystem snapshot at capture — see below
val acceptReason: String, // the Reasons vocabulary
)
providerFlags records what the location subsystem looked like when this point was
captured — which providers were on, the master switch, the permission tier, accuracy
authorization and airplane mode. Decode it with ProviderSnapshot:
data class ProviderSnapshot(
val recorded: Boolean = false, // false = no snapshot on this point
val gpsEnabled: Boolean = false,
val networkEnabled: Boolean = false,
val locationServicesEnabled: Boolean = false,
val airplaneMode: Boolean = false,
val authorizationStatus: Int = STATUS_DENIED, // 2 denied, 3 always, 4 while-in-use
val accuracyAuthorization: Int = ACCURACY_REDUCED, // 0 full, 1 reduced
)
val snapshot = ProviderSnapshot.fromFlags(point.providerFlags)
if (snapshot.recorded && !snapshot.gpsEnabled) {
// this point came from network positioning only
}
The two numeric fields carry wire codes, not an SDK enum, because they are a contract with
your backend and mean the same thing whichever platform sent them. Named constants are on the
companion: STATUS_NOT_DETERMINED (0), STATUS_RESTRICTED (1), STATUS_DENIED (2),
STATUS_ALWAYS (3), STATUS_WHEN_IN_USE (4), ACCURACY_FULL (0), ACCURACY_REDUCED (1).
Android cannot tell "never asked" from "asked and refused", so it never emits 0 for status.
recorded is false — and every other field meaningless — for points captured before the
SDK began recording this. That is deliberately distinct from a snapshot where everything is
off: "we did not look" and "location was disabled" are different answers about a point that
plainly exists.
It is captured per point rather than read when you ask, because the live ProviderState tells
you about now: a track recorded over an hour can span a permission downgrade, and the point
that stopped being precise is the one that carries the reason.
8.2 TrackSession
data class TrackSession(
val id: String,
val startedAtMs: Long,
val startedAtElapsedNanos: Long,
val endedAtMs: Long? = null,
val tag: String? = null,
val configSnapshot: String? = null, // the config in effect, so old tracks stay interpretable
) {
val isOpen: Boolean get() = endedAtMs == null
}
8.3 PointQuery
data class PointQuery(
val sessionId: String? = null,
val fromMs: Long? = null,
val toMs: Long? = null,
val limit: Int = 500,
val offset: Int = 0,
)
8.4 Enums
| Type | Values |
|---|---|
MovementStatus | STEADY, MOVING |
MotionState | STOPPED, MOVING, STOP_PENDING, STATIONARY |
ActivityType | IN_VEHICLE, ON_BICYCLE, ON_FOOT, WALKING, RUNNING, STILL, TILTING, UNKNOWN (+ isLowTier) |
MockPolicy | FLAG (default), REJECT, ALLOW |
PermissionTier | NONE, FOREGROUND_ONLY, FULL |
LocationAccuracy | APPROXIMATE, PRECISE |
PowerSource | NONE, AC, USB, WIRELESS, DOCK, UNKNOWN |
MotionQuality | FULL, DEGRADED, POOR |
GeofenceTransition | ENTER, EXIT |
ActivityType is enrichment only, never a capture gate — some devices report entire
17-minute drives as STILL under battery saver.
8.5 TrackFix — a raw fix
Returned by getCurrentLocation() and accepted by offerFix().
data class TrackFix(
val timeMs: Long,
val elapsedRealtimeNanos: Long,
val receivedAtElapsedNanos: Long,
val latitude: Double,
val longitude: Double,
val accuracy: Float,
val altitude: Double? = null,
val verticalAccuracy: Float? = null,
val speedMps: Float = 0f,
val bearingDeg: Float = 0f,
val hasSpeed: Boolean = false,
val hasBearing: Boolean = false,
val provider: String = TrackFix.UNKNOWN_PROVIDER,
val isMock: Boolean = false,
val satelliteCount: Int? = null,
val speedAccuracyMps: Float? = null,
val bearingAccuracyDeg: Float? = null,
)
9. Plotting and export
buildTrack() is the headline deliverable: a ready-to-draw track that any map library can
render without doing geometry. It runs entirely on-device — no backend, no routing key, no
quota — unless you install a RoadSnapProvider.
val track = tracker.buildTrack(
query = PointQuery(sessionId = sessionId),
options = TrackOptions(zoom = 15f, smoothing = Smoothing.SPLINE),
)
9.1 TrackOptions
| Field | Type | Default | What it does |
|---|---|---|---|
zoom | Float | 14f | Selects the arrow spacing tier |
includeRawPoints | Boolean | true | Emit the points array |
consolidateStops | Boolean | true | Collapse dwell clusters into stop nodes |
stopRadiusM | Double | 60.0 | Cluster radius for a stop |
stopMinDwellSec | Long | 600 | Minimum dwell to count as a stop |
smoothing | Smoothing | SPLINE | NONE, BEZIER, SPLINE, HEADING_SPLINE |
splineSpacingM | Double | 5.0 | Resample spacing for SPLINE |
bezierMinAngleDeg | Double | 30.0 | Only rounds vertices sharper than this (BEZIER) |
bezierCutbackM | Double | 25.0 | Corner cutback distance (BEZIER) |
snapToRoad | Boolean | true | Use road geometry if a provider is installed. Costs nothing with no provider |
snapMaxOffRoadM | Double | 80.0 | Beyond this from the returned road, a fix keeps its captured position |
polylinePrecision | Int | 6 | Encoded-polyline precision |
speedBandsKmph | List<Float> | [10f, 20f] | Thresholds for per-segment speed bands |
arrowMinSegmentM | Double | 60.0 | Minimum segment length to place an arrow |
simplifyEpsilonM | Double | 2.0 | Douglas-Peucker tolerance before smoothing. 0 disables |
Smoothing
| Value | Behaviour |
|---|---|
NONE | Chords between stored vertices, exactly as captured |
BEZIER | Round vertices sharper than bezierMinAngleDeg; every leg stays a chord |
SPLINE | Centripetal Catmull-Rom through every vertex, resampled. Default — a 120 m leg becomes a curve, not a chord |
HEADING_SPLINE | As SPLINE, but each vertex's recorded GNSS heading is the curve's tangent there instead of a direction inferred from its neighbours. Turns are the only place it differs, and there it is the difference between drawing the corner and cutting it. Falls back per vertex to the SPLINE tangent where no heading was recorded |
9.2 Track — the output
data class Track(
val version: Int = 1,
val sessionId: String? = null,
val generatedAtMs: Long = 0,
val from: Long = 0,
val to: Long = 0,
val timezone: String = "UTC",
val precision: Int = 6, // stated explicitly — never assume 5
val bounds: Bounds? = null, // null (never NaN-filled) when there are no points
val stats: TrackStats = TrackStats(),
val encodedPolyline: String = "",
val points: List<TrackJsonPoint> = emptyList(),
val segments: List<TrackSegment> = emptyList(),
val stops: List<StopNode> = emptyList(),
val arrows: List<ArrowAnchor> = emptyList(),
val warnings: List<String> = emptyList(),
)
warnings is an open string set: snap_unavailable, coarse_accuracy,
mock_locations_present, truncated, session_interrupted. Nothing is ever silently
dropped — anything omitted is named here.
TrackStats — distanceMeters, durationSec, movingSec, stoppedSec, maxSpeedMps,
avgMovingSpeedMps, pointCount, stopCount, activityBreakdownSec.
TrackSegment — from/to (inclusive indices into points), type (TRAVEL / STOP),
startMs, endMs, distanceMeters, durationSec, avgSpeedMps, maxSpeedMps,
p75SpeedMps, activity, activityIcon, speedBand, encodedPolyline, stopIndex.
StopNode — index, lat, lng, arrivalMs, departureMs, dwellSec, radiusM,
pointCount, address, isOngoing (pulse this marker — the session is still open).
ArrowAnchor — lat, lng, bearing, segment. Precomputed so the renderer and the
export cannot disagree about arrow placement.
TrackJsonPoint — i (the index every other array references), t, lat, lng, acc,
spd, brg, act, src, mock.
Bounds — north, south, east, west.
9.3 Export
val polylineJson = tracker.exportPolylineJson(PointQuery(sessionId = id))
val geoJson = tracker.exportGeoJson(PointQuery(sessionId = id))
exportGeoJson produces an RFC 7946 FeatureCollection — coordinates are [lng, lat].
Encode/decode helpers are public:
val encoded = PolylineCodec.encode(points, precision = 6)
val decoded = PolylineCodec.decode(encoded, precision = 6)
val json = TrackJson.encode(track)
val back = TrackJson.decode(json)
PolylineCodec.Encoder and PolylineCodec.Decoder are streaming variants — add() /
snapshot() and drain().
10. Live tracking
liveTrack() emits one frame per processed fix while a session is active: an append-only
smoothed tail, the re-smoothed last span, and the filter's own position estimate for an
animated puck. It is conflated — collectors always see the latest frame and can never slow
capture down.
Use liveTrack() for a map that follows the user; use buildTrack() for the consolidated,
snapped, segmented historical product.
lifecycleScope.launch {
tracker.liveTrack().collect { update ->
renderer.render(update)
}
}
10.1 LiveTrackUpdate
data class LiveTrackUpdate(
val sessionId: String,
val sequence: Long, // monotonic per session run — DROP a frame not newer than the last drawn
val precision: Int,
val frozenTailPolyline: String, // encoded; grows by appending. Never re-smooth it
val liveHead: List<GeoPoint>, // the unsettled last span, including both end vertices
val puck: PuckState?, // null until the filter seeds
)
data class PuckState(
val latitude: Double,
val longitude: Double,
val speedMps: Float,
val headingDeg: Double?, // null when velocity is too small; hold your last rotation
val accuracyM: Float, // 1σ uncertainty — the honest halo radius
)
liveHead's first vertex is the tail's last, so the two polylines join seamlessly.
10.2 Route snapping for the puck
tracker.setActiveRoute(routePolylinePoints) // List<GeoPoint>; pass emptyList() to clear
if (tracker.isOffRoute()) offerReroute()
This projects the live puck onto the route your app is already navigating — entirely offline,
no provider, no key, no quota. Only the puck moves. Stored points and buildTrack() are
untouched, because the route is your claim about where the user intends to go, not evidence of
where they were measured.
isOffRoute() becomes true only after the position misses the route for enough consecutive
fixes to be a wrong turn rather than a multipath spike. Always false with no route set.
11. Geofences
Up to 19 host fences. The SDK's internal stationary wake fence uses a reserved slot and does not count.
val fence = TrackerGeofence(
id = "warehouse",
latitude = 23.0225,
longitude = 72.5714,
radiusM = 200f,
onEnterEvent = "warehouse_enter",
onExitEvent = "warehouse_exit",
)
when (val result = tracker.addGeofence(fence)) {
is TrackerResult.Ok -> Unit
is TrackerResult.Error -> when (result.code) {
ErrorCode.GEOFENCE_LIMIT_REACHED -> pruneOldFences()
ErrorCode.GEOFENCE_REGISTRATION_FAILED -> retryLater()
ErrorCode.INVALID_CONFIG -> fixCoordinates()
else -> Unit
}
}
Validation: non-blank id, latitude in −90..90, longitude in −180..180, radiusM > 0.
Crossings arrive as TrackerEvent.GeofenceEntered / GeofenceExited and are also persisted:
val history: List<TrackerGeofenceEvent> = tracker.getGeofenceEvents(
geofenceId = "warehouse",
fromMs = startOfDay,
limit = 100,
)
val deleted: Int = tracker.deleteGeofenceEvents(geofenceId = "warehouse")
data class TrackerGeofenceEvent(
val geofence: TrackerGeofence,
val transition: GeofenceTransition, // ENTER | EXIT
val timestampMs: Long,
val eventName: String,
)
Constants: TrackerGeofence.MAX_GEOFENCES = 19, DEFAULT_ID = "trackit-stationary",
DEFAULT_ENTER_EVENT, DEFAULT_EXIT_EVENT.
12. Battery and sensors
val now: BatteryInfo = tracker.batteryInfo() // reads the platform right now
val live: StateFlow<BatteryInfo> = tracker.batteryState()
batteryInfo() needs no session, no permission and no ready() call. It is a binder call —
put it in a refresh, not a per-frame render; collect batteryState() for a live display.
data class BatteryInfo(
val percent: Int? = null, // null means "we do not know" — never 0 %
val isCharging: Boolean? = null,
val powerSource: PowerSource = PowerSource.UNKNOWN,
) {
val isLow: Boolean // percent != null && percent <= 15
}
This is the same reading stamped on every stored point, so your display and your uploaded rows
cannot disagree. TrackerEvent.BatteryChange carries the same transitions.
val sensors: DeviceSensors = tracker.getSensors()
data class DeviceSensors(
val accelerometer: Boolean,
val gyroscope: Boolean,
val magnetometer: Boolean,
val significantMotion: Boolean,
val stepDetector: Boolean,
val stepCounter: Boolean,
val barometer: Boolean,
val rotationVector: Boolean,
val motionQuality: MotionQuality, // FULL | DEGRADED | POOR
)
12.1 Motion hardware, and what it can override
MotionQuality is not a diagnostic. The SDK acts on it at ready(), before any session
opens, because running a motion-gated design on hardware that cannot detect motion produces
gaps a user blames on the SDK rather than on the device (EC-137).
MotionQuality | Derived when | What the SDK does about it |
|---|---|---|
FULL | Accelerometer and gyroscope and (significant-motion or step-detector) | Nothing — your config runs as written |
DEGRADED | Anything between the two | motion.stopTimeoutMin is doubled, mode untouched. A Diagnostic event names the old and new value |
POOR | No accelerometer, or ACTIVITY_RECOGNITION denied with no significant-motion and no step-detector | trackingMode is forced to CONTINUOUS (unless it already is). TrackerEvent.Error(MOTION_DETECTION_DEGRADED) names the missing sensors |
Three things about this are worth knowing before you debug a device.
POOR is not purely a hardware verdict. Read the second half of that row again: a
denied ACTIVITY_RECOGNITION runtime permission reaches POOR on a device with no
significant-motion or step sensor, and plenty of otherwise-capable mid-range hardware has
neither. The message then reads accelerometer=true, which looks self-contradictory until
you notice the permission half. Check the grant before you blame the phone. The verdict
changes when the grant does, so re-read it after a permission flow rather than caching it
from startup.
POOR costs battery. CONTINUOUS keeps the location stream registered while
stationary and MOTION_ONLY does not — see §5.2. The override is
the right trade (gaps are worse than power) but it is the opposite of what a host choosing
MOTION_ONLY asked for, so the event message says so explicitly.
DEGRADED widens rather than overrides. Stops on such a device are detected later and
less certainly. Waiting longer before believing one is the cheaper error: a late stop costs
a few extra fixes, a false stop costs the rest of the trip. The value is doubled rather
than replaced with a constant, so your own timeout still expresses your use case.
12.2 Reading it
// The verdict, and whether it changed your mode. Always current, whenever you subscribe.
tracker.state.value.motionQuality // FULL | DEGRADED | POOR
tracker.state.value.effectiveTrackingMode
// Which sensors explain that verdict.
tracker.getSensors()
Use TrackerState for the verdict and getSensors() for the detail.
Do not rely on the MOTION_DETECTION_DEGRADED event alone: it is emitted inside
ready(), and events has replay = 0, so a collector created afterwards — the
documented and usual order — never receives it. See
§7.1. The event is still worth handling if you collect
early; it is simply not a reliable way to discover the condition.
Both state fields are set by ready() and hold until the next ready() call, so a host
that re-runs ready() after granting ACTIVITY_RECOGNITION gets a re-evaluated verdict —
which is the recovery path when the cause was a denied permission rather than absent
hardware.
13. Maps module
fieldtrack-maps renders a Track and a LiveTrackUpdate on a GoogleMap. Both renderers are
main-thread only, not views — construct where the map lives, call render(), call clear()
when the map goes away.
13.1 TrackRenderer — historical track
val renderer = TrackRenderer(googleMap, TrackRenderer.RendererOptions())
renderer.render(track, fitCamera = true)
googleMap.setOnCameraIdleListener {
if (renderer.needsArrowRefresh()) {
// rebuild with the new zoom, then render again
}
}
renderer.clear()
TrackRenderer.RendererOptions
| Field | Default |
|---|---|
basePathColor | Color.argb(190, 66, 66, 66) |
basePathWidth | 16f |
speedOverlayWidth | 16f |
speedOverlayAlpha | 160 |
cameraPaddingPx | 80 |
cameraPaddingFallbackPx | 50 |
arrowSizePx | 48 |
arrowColor | Color.WHITE |
showStopMarkers | true |
showArrows | true |
13.2 LiveTrackRenderer — live puck
val live = LiveTrackRenderer(
googleMap,
LiveTrackRenderer.Options(cameraFollow = LiveTrackRenderer.CameraFollowMode.FOLLOW_BEARING),
)
lifecycleScope.launch { tracker.liveTrack().collect(live::render) }
live.cameraFollow = LiveTrackRenderer.CameraFollowMode.NONE // switchable at runtime
live.clear()
LiveTrackRenderer.CameraFollowMode
| Value | Behaviour |
|---|---|
NONE | Camera untouched — you own it |
FOLLOW | Centre on the puck, north-up, keeping the user's zoom |
FOLLOW_BEARING | Navigation look: puck-centred, heading-up, tilted |
LiveTrackRenderer.Options
| Field | Default |
|---|---|
tailColor / headColor | Color.argb(230, 26, 115, 232) |
tailWidth / headWidth | 14f |
puckSizePx | 56 |
puckColor | Color.rgb(26, 115, 232) |
showAccuracyHalo | true |
haloFillColor | Color.argb(26, 26, 115, 232) |
haloStrokeColor | Color.argb(90, 26, 115, 232) |
animationDurationMs | 1_000 — ease duration ≈ the fix interval |
lookaheadMs | 1_000 — dead-reckoning horizon; match animationDurationMs |
cameraFollow | CameraFollowMode.NONE |
followZoom | 17f — applied on the first followed frame only |
followTilt | 50f |
Stale frames (sequence not newer than the last drawn) are dropped automatically.
13.3 ArrowIcons
ArrowIcons.chevron(sizePx = 48, color = Color.WHITE)
ArrowIcons.numberedPin(/* … */)
ArrowIcons.puck(sizePx = 56, color = Color.rgb(26, 115, 232))
14. Sync module — upload to your backend
fieldtrack-core never opens a socket. fieldtrack-sync does. An app that does not depend
on it gets an offline-first SDK with no network code linked at all.
val sync = TrackerSync.getInstance(context) // @JvmStatic, idempotent, paired with Tracker.getInstance
sync.configure(
SyncConfig.builder()
.baseUrl(BuildConfig.API_BASE_URL) // "https://api.example.com"
.path("v1/location/batch")
.header("Authorization", "Bearer $token")
.batchSize(100)
.autoSync(true)
.build()
)
If you set TrackerConfig.baseUrl, you can supply only a path here — the base is resolved from
config. An absolute url on SyncConfig always wins over TrackerConfig.baseUrl; the base
is a fallback, never an override.
14.1 SyncConfig
| Field | Type | Default | What it does |
|---|---|---|---|
url | String | — | Full endpoint. Must be https:// (or http:// for loopback / with allowCleartext) |
method | String | "POST" | HTTP method. POST, PUT or PATCH only — see below |
headers | Map<String, String> | empty | Sent on every request. Never exposed back — they carry your credential |
autoSync | Boolean | true | Upload as points arrive. With it off, you call syncNow() / requestSync() |
batchSize | Int | 100 | Rows per request, 1..1000. Larger = fewer requests but a bigger retry unit |
requiresUnmeteredNetwork | Boolean | false | Only upload on Wi-Fi |
gzipRequestBody | Boolean | false | Compress the JSON body. Off by default — there is no negotiation for request-body encoding, so a server that does not expect gzip answers 400 |
allowCleartext | Boolean | false | Permit an http:// URL. Local development only. Loopback hosts (localhost, 127.0.0.1, ::1, 10.0.2.2) are already exempt |
timeouts | SyncTimeouts | 5 s / 30 s / 20 s | Applied by the built-in transport; ignored by a custom one |
extraParams | Map<String, Any> | empty | Merged into the top level of every request body, alongside the location array — see §14.1.1 |
methodacceptsPOST,PUTorPATCH, and nothing else. The built-in transport is Retrofit, whose verb annotations are compile-time constants — there is no dynamic-verb form — so the transport dispatches over a fixed set rather than passing your string through.configure()rejects anything outside it, which at least surfaces the problem at configuration time rather than on the first upload hours later.This is a narrowing. The previous OkHttp transport passed any verb straight to
Request.Builder.method(...). If you need another one, supply your ownSyncTransport(§14.6) — the interface has no such restriction.
Builder: .url(), .baseUrl(), .path(), .method(), .header(name, value),
.headers(map), .autoSync(), .batchSize(), .requiresUnmeteredNetwork(),
.gzipRequestBody(), .allowCleartext(), .timeouts(SyncTimeouts),
.timeouts(connectMs, readMs, writeMs), .extraParam(name, value), .extraParams(map),
.build(), .buildUnchecked().
baseUrl and path are joined with exactly one / regardless of which side carries it.
14.1.1 extraParams — your own body fields
Most backends want the batch inside an envelope carrying identity, not on its own. Anything
you put in extraParams is merged into the top level of the request body, before the
location array:
SyncConfig.builder()
.baseUrl(BuildConfig.API_BASE_URL)
.path("v1/location/batch")
.header("Authorization", "Bearer $token")
.extraParam("user_id", userId)
.extraParam("device_id", deviceId)
.extraParam("company_id", 7)
.build()
produces:
{
"user_id": "u-42",
"device_id": "d-88",
"company_id": 7,
"location": [ { "uuid": "…" } ]
}
Values may be a String, a Boolean, any boxed number, or a Map / List / array of those
for nested structures. Numbers stay numbers and booleans stay booleans — nothing is
stringified on the way out. null is not a value: omit the key instead.
configure() rejects an unusable value rather than failing on the first upload. An
unserializable object is reported by key name at configuration time — the point where you are
still holding the config you wrote — rather than mid-drain hours later, where a batch that
cannot be sent has no good answer. The key location is reserved, since that is the batch
itself.
With no extraParams set, the body is byte-identical to previous releases — this is
additive, and an existing backend needs no change.
These are static config, like headers. A rotating token belongs in a re-configure()
call, or in your own SyncTransport where you can compute it per request.
data class SyncTimeouts(
val connectMs: Long = 5_000,
val readMs: Long = 30_000,
val writeMs: Long = 20_000,
)
14.2 TrackerSync API
| Member | Signature | Notes |
|---|---|---|
getInstance | @JvmStatic fun getInstance(context: Context): TrackerSync | Idempotent, thread-safe |
configure | fun configure(config: SyncConfig, transport: SyncTransport? = null) | Throws IllegalArgumentException on an invalid config. Omit transport to use the built-in Retrofit-over-OkHttp default |
endpoint | val endpoint: String? | Where uploads go, or null if unconfigured — or if a 401 tore it down. Headers are deliberately not exposed |
isConfigured | val isConfigured: Boolean | Derived from endpoint. Do not cache it — a 401 clears configuration with no involvement from you |
pendingCount | suspend fun pendingCount(): Int | Rows waiting to upload |
requestSync | fun requestSync() | Enqueues a network-constrained one-shot via WorkManager. Safe to call often. No-op once halted by a 403 |
syncNow | suspend fun syncNow(): SyncQueue.Result | Drains inline in the caller's scope. Prefer requestSync() for anything not user-initiated |
events | val events: SharedFlow<SyncEvent> | One event per completed exchange, including background drains. Replay 1 |
14.3 Results and events
sealed interface SyncQueue.Result {
data class Uploaded(val count: Int) : Result
data object Empty : Result
data class Retry(val reason: String, val retryAfterMs: Long? = null) : Result
data object AuthExpired : Result // 401
data object Forbidden : Result // 403
}
sealed interface SyncEvent {
data class HttpResponse(val statusCode: Int?, val count: Int) : SyncEvent
data class NetworkAvailable(val queued: Int) : SyncEvent
}
statusCode is null when no HTTP response arrived at all (dead network, DNS failure,
timeout). That is a device problem; a 500 is a server problem — do not report them the same way.
count is what was attempted, not what was stored.
NetworkAvailable fires when the device returns to a usable network and rows are actually
queued — a reconnection with an empty queue is silent. queued is the depth at that moment. It
says a drain was requested, not that one succeeded; the HttpResponse that follows is the
outcome. See When the network comes back.
lifecycleScope.launch {
sync.events.collect { event ->
when (event) {
is SyncEvent.HttpResponse -> showLastUpload(event.statusCode, event.count)
is SyncEvent.NetworkAvailable -> showBacklog(event.queued)
}
}
}
events is a SharedFlow with replay = 1, so a screen opened after a background drain sees
the last event rather than a blank panel.
14.4 Terminal failure semantics
| Status | Behaviour |
|---|---|
| 2xx | Batch accepted and marked synced |
| 401 Unauthorized | Terminal. Tracking is stopped, the upload queue is cleared, and the config is forgotten. The credentials this session was recorded under are gone; keeping the queue would leak the previous user's positions into the next login |
| 403 Forbidden | Terminal, but non-destructive. Uploads halt, rows are kept, tracking continues. Recovery is calling configure() again with a working credential |
| Anything else | Rows stay queued and retry with linear backoff (30 s base) via WorkManager, network-constrained |
Retry-After header | Honoured — the server's own schedule replaces the SDK's |
Not every Retry is a failed exchange. Retry("already draining") means another drain holds the
lock and is doing the work; Retry("sync not configured") and Retry("no transport") mean there
was nothing to attempt. None of the three consume a backoff attempt — surface them as information,
not as an upload error.
One visible side effect of both terminal cases: if you turned on
showSyncStatusInNotification, the upload-status subtitle and line disappear
with the config and the notification goes back to your own notificationText. That is the same
reading as "sync was never configured", so a status line that vanishes mid-session means a 401
or 403 landed — check SyncEvent.HttpResponse for which. After a 403 the rows are still on
disk and resume uploading once configure() is called with a working credential; after a 401
they are gone.
When the network comes back
Recovering from offline has two independent halves, and you need neither of them to do anything:
- Durable. Every drain is enqueued as network-constrained WorkManager work, persisted in WorkManager's own database. It survives process death and reboot, so a backlog recorded offline uploads even if the app is killed before connectivity returns.
- Prompt. While the process is alive, the SDK watches the default network and asks for a drain
the moment the device is on a validated one — not merely a connected one, so a captive portal
is not mistaken for internet. Rising edges only, throttled to one request per 15 s, and only
when the queue is non-empty. This is what emits
SyncEvent.NetworkAvailable.
The prompt half needs autoSync = true; with it off you own the schedule and nothing drains
unless you call syncNow(). Both halves stop after a 401 or 403.
Queue order is FIFO — oldest row first, across every unsent session, by insertion order rather than by any device clock. A backlog that spans a reboot still uploads in the order it was recorded.
14.5 The wire format
The complete request contract: what the SDK sends, what every field means, and every limit that applies. JSON, snake_case keys, epoch milliseconds throughout.
14.5.1 The request line and headers
As sent by the built-in transport. A custom SyncTransport
(§14.6) owns the whole exchange and none of the header behaviour
below applies to it.
| Part | Value | Limits |
|---|---|---|
| Method | SyncConfig.method | POST, PUT or PATCH only. Rejected at configure() otherwise |
| URL | SyncConfig.url | Must be https://. http:// only for loopback (localhost, 127.0.0.1, ::1, 10.0.2.2) or with allowCleartext = true |
Content-Type | application/json; charset=utf-8 | Not configurable |
Content-Encoding | gzip, only when gzipRequestBody = true and the body is at least 1,024 characters | Below that the gzip header and trailer cost more than the saving, so the body is sent uncompressed and this header is omitted entirely. Your server must handle both, on the same endpoint, with the same config |
| Your headers | SyncConfig.headers | Sent on every request. Never read back by any SDK API — they carry your credential |
There is no request-body encoding negotiation, so gzipRequestBody is off by default:
a server that does not expect gzip answers 400 or stores the compressed bytes as the
payload. Turn it on only once your server is known to decode it.
14.5.2 The body envelope
{
"user_id": "u-42",
"device_id": "d-88",
"location": [
{ "uuid": "0f5c8f0e-…", "time": 1755500000000 },
{ "uuid": "1a6d9e1f-…", "time": 1755500030000 }
]
}
Points are abbreviated here — the full object is in §14.5.3.
| Key | Type | Notes and limits |
|---|---|---|
| (your keys) | any JSON | Whatever you put in extraParams, in insertion order, before location. Values may be a string, boolean, number, or a map/list of those, nested up to 10 levels. Types are preserved — a number stays a number. Rejected at configure() if unserializable |
location | array | The batch. Reserved — extraParams may not use this key. Never empty: a drain with nothing queued sends no request at all |
Rows per request is batchSize (default 100, valid range 1–1000). A single drain
uploads at most 20 batches before returning, so one drain moves at most
20 × batchSize rows; a larger backlog is picked up by the next trigger. This bound exists
so one call cannot hold the queue through an unbounded backlog.
14.5.3 One point
{
"uuid": "0f5c8f0e-1c2a-4f0b-9a3c-7d1e2b3a4c5d",
"time": 1755500000000,
"local_date": "2026-08-18",
"latitude": 23.0225,
"longitude": 72.5714,
"accuracy": 8.4,
"movementSpeed": 12.5,
"provider": {
"network": true,
"gps": true,
"enabled": true,
"status": 3,
"accuracyAuthorization": 0,
"airplane": false
},
"hasSpeed": true,
"hasBearing": true,
"time_zone": "Asia/Kolkata",
"activity_status": "fused@moving",
"detected_activity_type": "IN_VEHICLE",
"detected_activity_start_time": 1755499000000,
"battery_percentage": "62",
"is_charging": false,
"is_mock": false,
"integrity_flags": 0,
"integrity_signals": []
}
| Field | Type | Always sent? | Meaning and limits |
|---|---|---|---|
uuid | string | yes | Stable identity for this point. Dedupe on this — see §14.5.5 |
time | number | yes | Capture time, epoch milliseconds, wall clock. Subject to device clock changes; integrity_flags reports when the clock looked untrustworthy |
local_date | string | yes | yyyy-MM-dd in the point's own time_zone, for day bucketing without server-side zone maths |
latitude / longitude | number | yes | WGS-84 degrees. The fix's own coordinates, not a filtered estimate |
accuracy | number | yes | Horizontal error radius in metres, as the platform reported it. No upper bound — a bad indoor fix can be thousands |
movementSpeed | number | yes | Metres per second. 0.0 when the provider reported no speed — check hasSpeed before trusting it |
provider | object | no | Location subsystem at capture time. See §14.5.4. Absent on points captured before the SDK recorded it |
hasSpeed / hasBearing | boolean | yes | Whether the provider actually supplied the value. 0.0 is a legal speed, so this is the only way to tell "stationary" from "not reported" |
time_zone | string | yes | IANA id, per point — a session can cross zones on a flight, so do not assume one zone per batch |
activity_status | string | yes | "<provider>@<movementStatus>", lowercase — e.g. fused@moving, gps@steady. Provider is one of fused, gps, network, passive, unknown; movement is moving or steady. This is where the provider name lives |
detected_activity_type | string | no | One of IN_VEHICLE, ON_BICYCLE, ON_FOOT, WALKING, RUNNING, STILL, TILTING, UNKNOWN. Enrichment only — see the caveat below |
detected_activity_start_time | number | yes | Epoch ms when that activity began; 0 when unknown |
battery_percentage | string | no | 0–100 as a string, e.g. "62". Absent when the platform will not say |
is_charging | boolean | no | Plugged in or full. Absent — not false — when the platform will not say |
is_mock | boolean | yes | The fix was flagged as mock by the OS. Android-only concept. Whether mock points are sent at all depends on policy — see §19.2 |
integrity_flags | number | yes | Device-integrity bitmask at capture. 0 = nothing observed. Bit values are frozen — see §19.4 |
integrity_signals | array of string | yes | The same information by name, for rules that prefer strings to bits. [] when nothing was observed |
detected_activity_typeis not a capture gate and should not be one server-side either. Entire multi-minute drives are reportedSTILLby some devices under battery saver. Treat it as a hint, never as ground truth about whether the user moved.
14.5.4 The provider object
| Field | Type | Meaning and limits |
|---|---|---|
network | boolean | The network (Wi-Fi/cell) provider is enabled |
gps | boolean | The GPS provider is enabled |
enabled | boolean | The location master switch. Not the union of the two above — a device can report location enabled with GPS off. Below Android 9 the platform exposes no master switch, so this falls back to the union |
status | number | Permission tier: 0 not determined, 1 restricted, 2 denied, 3 always (foreground + background), 4 while in use. Android never sends 0 — it cannot distinguish "never asked" from "asked and refused", so both are 2 |
accuracyAuthorization | number | 0 full (fine location), 1 reduced (coarse only). Reduced means a 1–3 km error circle |
airplane | boolean | Airplane mode was on. Not a gate — GPS keeps working in airplane mode on most devices while network positioning does not |
Recorded per point, not sampled when the queue drains. A batch can span an hour, and a permission downgrade inside that hour is exactly what explains a gap — a single snapshot taken at upload time would stamp every row with whatever happened to be true minutes later.
The key is omitted for points captured before the SDK recorded it. That is deliberately
not an object full of false: "we did not look" and "everything was off" are different
answers about a point that plainly exists.
Breaking change from earlier releases:
providerwas the provider name as a string ("fused"). The name is still on the wire — read it fromactivity_status, which is"<provider>@<movementStatus>"and always was.
14.5.5 Rules your server must follow
Dedupe on uuid. A failed batch is re-sent whole on the next attempt, so duplicate
delivery is guaranteed by design, not an edge case. A batch that your server stored but
failed to acknowledge — a timeout after the write, a 502 from a proxy — arrives again.
Absent is not null. Nullable fields are omitted from the object, never sent as
null. If detected_activity_type, battery_percentage, is_charging or provider is
unknown, the key is simply not there. A parser that distinguishes the two needs to know
this; it is pinned by an automated test, so it cannot change silently.
New fields will appear. Every field added so far has been additive with a default, and
parsing must tolerate unknown keys. Conversely, integrity_flags and integrity_signals
are always sent by clients that support them — a client version known to send them that
suddenly stops is worth treating as suspicious.
Answer with the right status. The status code alone decides what the SDK does; the success body is ignored entirely.
| Status | SDK behaviour |
|---|---|
| 2xx | Accepted. Rows marked synced, next batch drains immediately |
| 401 | Terminal. Tracking stops, the queue is cleared, config is forgotten |
| 403 | Terminal for retrying. Uploads halt, rows are kept, tracking continues |
| Anything else | Retried with backoff, rows kept |
See §14.4 for why 401 and 403 differ. Send Retry-After
on a 429 or 503 to control the next attempt: both RFC 9110 forms are accepted
(delta-seconds or an HTTP-date), and the value is clamped to 1 second – 6 hours so one
bad header cannot park the queue indefinitely.
On a non-2xx the SDK keeps at most 4,096 characters of your response body for the host
to inspect, so 500 can be told apart from 500 {"error":"bad geometry"}. A longer body is
truncated, not rejected. It is never logged, because an error body can echo a request
header. Success bodies are discarded unread.
Treat integrity fields as advisory. integrity_flags is input to a server-side rule,
not the defence itself — it is a client-side observation, and a client is not a trustworthy
narrator about itself.
14.6 Custom transport
Supply your own SyncTransport to reuse an existing authenticated client — then neither
Retrofit nor OkHttp is linked, and you can remap the payload to whatever your backend
expects. The example below uses OkHttp because that is what most hosts already have; the
interface has no opinion.
class MyTransport(private val client: OkHttpClient) : SyncTransport {
override suspend fun upload(request: SyncRequest): SyncResponse = try {
val response = client.newCall(request.toOkHttp()).execute()
when (response.code) {
in 200..299 -> SyncResponse.Success(response.code)
401 -> SyncResponse.Unauthorized
403 -> SyncResponse.Forbidden
else -> SyncResponse.Failure(response.code, response.message)
}
} catch (e: IOException) {
SyncResponse.Failure(null, e.message ?: "network error")
}
}
sync.configure(config, MyTransport(myClient))
Implementations must not throw — a network failure is an expected state, and the queue depends on being told which of the three it was.
data class SyncRequest(
val url: String,
val method: String,
val headers: Map<String, String>,
val jsonBody: String,
val gzip: Boolean = false,
val timeouts: SyncTimeouts = SyncTimeouts(),
)
sealed interface SyncResponse {
data class Success(val code: Int) : SyncResponse
data object Unauthorized : SyncResponse
data object Forbidden : SyncResponse
data class Failure(
val code: Int?,
val message: String,
val body: String? = null, // at most 4096 chars; never logged by the SDK
val retryAfterMs: Long? = null,
) : SyncResponse
}
15. Snap module — road matching
Optional. With no provider installed, buildTrack() never leaves the device and never emits a
snap_unavailable warning.
tracker.setRoadSnapProvider(
OsrmSnapProvider(baseUrl = "https://osrm.example.com")
)
There is no default baseUrl on purpose — the public OSRM demo server has no availability
guarantee. Point this at your own deployment.
OsrmSnapProvider parameters
| Parameter | Default | What it does |
|---|---|---|
baseUrl | — | Your OSRM server |
profile | "driving" | OSRM profile |
client | built-in OkHttp | Supply your own. Retrofit runs on top of whatever you pass, so proxies, pinning and interceptors are all still yours |
chunkSize | provider default | Coordinates per /match request |
searchRadiusM | provider default | Search radius per coordinate |
headers | empty | Extra request headers |
minConfidence | provider default | Matchings below this are discarded and keep raw coordinates. 0 accepts everything |
cacheEntries | ChunkCache.DEFAULT_MAX_ENTRIES | Matched chunks kept between calls — the whole value when a live map rebuilds the track on every fix. 0 disables |
It degrades per chunk, never wholesale: a trace split across ten requests does not lose the nine that succeeded because the tenth was rate-limited.
15.1 Writing your own provider
interface RoadSnapProvider {
suspend fun snap(path: List<GeoPoint>): List<GeoPoint>
suspend fun snap(request: SnapRequest): List<GeoPoint> = snap(request.path) // richer, optional
object Disabled : RoadSnapProvider
}
data class SnapFix(val point: GeoPoint, val timeMs: Long = 0, val accuracyM: Float = 0f)
data class SnapRequest(val fixes: List<SnapFix>) {
val path: List<GeoPoint>
val hasTimestamps: Boolean
}
Implementations must degrade rather than fail: returning an empty list makes the builder fall
back to raw geometry and emit snap_unavailable rather than losing the track. Any exception you
do throw is caught and turned into ErrorCode.SNAP_UNAVAILABLE — it is never fatal.
The 80 m snapMaxOffRoadM guard means a parallel service road can never relocate the user.
16. Diagnostics
Three layers, from rawest to most interpreted.
16.1 Layer 1 — raw fixes
Requires persistence.persistRawFixes = true.
val raw: List<RawFix> = tracker.getRawFixes(sessionId)
data class RawFix(
val timeMs: Long,
val latitude: Double,
val longitude: Double,
val accuracy: Float,
val bearingDeg: Float, // 0f when the provider reported no bearing
val provider: String,
val integrityFlags: Int, // device-integrity bitmask when received — see §19.4
)
16.2 Layer 2 — raw points
Requires persistence.persistRawPoints = true. Every judged fix in point form, accepted or not
— the layer to reach for when the question is "why is there no point here" rather than "why
is this point wrong". RawPoint has the same columns as TrackPoint plus:
| Field | Meaning |
|---|---|
verdict | "ACCEPT", "SKIP" or "REJECT" |
reason | The Reasons vocabulary string |
isAccepted | verdict == "ACCEPT" |
RawPoint.uuid joins back to the stored TrackPoint for accepted fixes.
RawPoint carries providerFlags too, and on this layer it is worth more than on the accepted
one: a run of rejects whose snapshot shows accuracyAuthorization = ACCURACY_REDUCED is a
permission problem, not a filter problem, and the two look identical from the point table alone.
16.3 Layer 3 — the decision log
On by default (persistence.persistDecisions = true).
val decisions: List<FixDecision> = tracker.getDecisions(sessionId, limit = 200, offset = 0)
data class FixDecision(
val fix: TrackFix,
val verdict: Verdict, // Accept | Skip | Reject, each carrying a reason
val filterLat: Double,
val filterLng: Double,
val sigma: Float, // how far the fix was, in filter sigmas
val threshold: Float, // how wide the gate was
val distanceMovedM: Double,
val effectiveSpeedMps: Float,
val motionState: MotionState,
) {
val reason: String
val isAccept: Boolean
}
The numeric fields exist so a Sigma Gate Outlier can be argued with.
16.4 Reasons — the reason vocabulary is API
These exact strings appear on TrackPoint.acceptReason, RawPoint.reason and
FixDecision.reason. They are stable; changing one is a breaking change.
| Constant | String |
|---|---|
INIT | Init |
RESUME | Resume |
BURST | Burst |
NLP_FALLBACK | NLP Fallback |
IMPOSSIBLE_SPEED | Impossible Speed |
POOR_ACCURACY | Poor Accuracy |
RECOVERY_CONFIRMED | Recovery Confirmed |
RECOVERY_RESET | Recovery Reset |
RECOVERY_HELD | Recovery Held |
SIGMA_GATE_OUTLIER | Sigma Gate Outlier |
SIGMA_FORCED_RESET | Sigma Forced Reset |
SIGMA_JUNK_FAIL | Sigma Junk Fail |
VEHICULAR | Vehicular |
MOVING_WALKING | Moving/Walking |
INDOOR_ARRIVAL | Indoor Arrival |
BEARING_CHANGE | Bearing Change |
CORNER_ANCHOR | Corner Anchor |
ARRIVAL | Arrival |
STATIONARY_RECOVERY | Stationary Recovery |
BLACKOUT_ARRIVAL | Blackout Arrival |
WALK_ARRIVAL | Walk Arrival |
HEARTBEAT | 15-Min Heartbeat |
ORIGIN_SET | Origin Set |
DEPARTURE_HELD | Departure Held |
DRIFT_SUPPRESSED | Drift Suppressed |
HEARTBEAT_SKIPPED | HeartBeat Skipped |
HEURISTIC_GATE | Heuristic Gate |
SESSION_CLOSED | Session Closed |
MOCK_LOCATION | Mock Location |
INVALID_COORDINATES | Invalid Coordinates |
STALE_FIX | Stale Fix |
REBOOT_BOUNDARY | Reboot Boundary |
OUT_OF_ORDER | Out Of Order |
17. Java interop
Every entry point is Java-callable. getInstance, TrackerConfig.builder() and
SyncConfig.builder() are @JvmStatic; PointQuery, TrackOptions and the paged query
methods carry @JvmOverloads.
Tracker tracker = Tracker.getInstance(context);
TrackerConfig config = TrackerConfig.builder()
.provider(LocationProviderType.GPS_ONLY)
.accuracyProfile(AccuracyProfile.STRICT)
.intervalMs(30_000L)
.notification("Tracking", "Recording your route")
.build();
suspend functions need a coroutine. From Java, call them from Kotlin glue, or wrap them in
your own CoroutineScope helper. Flows are consumed the same way.
TrackerConfig.Builder.build() and SyncConfig.Builder.build() throw
IllegalArgumentException on an invalid config — use buildUnchecked() plus validate() if
you are assembling config from untrusted input.
18. ProGuard / R8
You do not need to add any rules. Each AAR ships consumer-rules.pro and the published
artifacts are already R8-minified.
What this means in practice:
- Public API types and the documented extension seams (
TrackLogger,RoadSnapProvider,SyncTransport) keep their names. - Model classes (
Track,TrackOptions,TrackSegment,TrackStats,TrackJsonPoint,StopNode,ArrowAnchor,LiveTrackUpdate,PuckState,SegmentType,Smoothing, …) keep public class and member names, so named accessors survive. - Enum constants are preserved — persisted rows and wire values use
name/valueOf. - SDK logging is compiled out of release builds entirely.
- No sources JAR is published; a Javadoc JAR with rendered public API HTML is.
If you hit a NoSuchMethodError or a serialization failure after enabling minification in your
own app, that is a bug worth reporting — do not paper over it with
-keep class com.field360.tracker.** { *; }, which would disable shrinking for the whole SDK
inside your APK.
19. Device integrity
A second security layer beside the license gate. It answers one question — can this device fabricate the location data it is about to send? — and lets you decide what to do about the answer.
Release only. Every probe is skipped and every policy ignored when the host app is debuggable, exactly as the license check is waived there. Development builds, emulators and instrumentation runs are unaffected, with nothing to remember to switch off and nothing that could survive into production.
19.1 What is checked
| Signal | How | Default |
|---|---|---|
ACCESSIBILITY_SERVICE_ACTIVE | A non-system accessibility service is enabled — the usual driver for UI automation | WARN |
DEVELOPER_MODE_ENABLED | Settings.Global.DEVELOPMENT_SETTINGS_ENABLED | WARN |
ADB_ENABLED | Settings.Global.ADB_ENABLED | WARN |
HOOKING_FRAMEWORK_DETECTED | Frida/Xposed: mapped libraries, agent thread names, default ports 27042/27043, TracerPid. Weighted; raised at confidence ≥ 60 | BLOCK |
DEBUGGER_ATTACHED | TracerPid non-zero or Debug.isDebuggerConnected() | BLOCK |
AUTO_TIME_DISABLED | Automatic date/time and automatic time zone both off | WARN |
TIMEZONE_MISMATCH | Device time zone not used in the serving cellular network's country | WARN |
CLOCK_SKEWED | System clock disagrees with GNSS UTC by more than maxClockSkewMs | WARN |
MOCK_LOCATION_APP_SELECTED | A visible installed package holds the mock-location app-op | BLOCK |
MOCK_LOCATION_FIX | The platform flagged a delivered fix as mock | BLOCK |
No new permission is required, and QUERY_ALL_PACKAGES is deliberately not requested
— see §19.5.
19.2 Policy
Three levels per group of signals:
| Policy | Reported to the host | Stamped on points and uploaded | Blocks ready()/start() |
|---|---|---|---|
ALLOW | no | no | no |
WARN | yes | yes | no |
BLOCK | yes | yes | yes |
val config = TrackerConfig.builder()
.securityEnabled(true) // default
.hookingPolicy(IntegrityPolicy.BLOCK) // default
.mockLocationIntegrityPolicy(IntegrityPolicy.BLOCK) // default
.accessibilityPolicy(IntegrityPolicy.WARN) // default
.developerModePolicy(IntegrityPolicy.WARN) // default
.clockPolicy(IntegrityPolicy.WARN) // default
.accessibilityAllowlist(setOf("com.yourco.kiosk"))
.maxClockSkewMs(120_000) // default
.integrityRecheckIntervalMs(15 * 60_000) // default; 0 disables
.build()
accessibility defaults to WARN on purpose: accessibility services are also how blind
and motor-impaired users operate a phone, and blocking on them would lock those users out
of your app. Services installed as part of the system image never raise a finding.
Setting mockLocationIntegrityPolicy(BLOCK) forces mockLocationPolicy = REJECT; the two
cannot be left contradicting each other. An SDK that refuses to run on a mocked device cannot
also be storing mocked points, so the stricter of the two wins, silently — a validation error
would fail ready() over a combination the SDK can resolve correctly on its own.
A debuggable build is exempt from that forcing. Both settings default to strict, so without the exemption a developer feeding a fake route through the emulator got a total, silent data loss: every fix dropped before it reached storage, nothing in the database, and nothing in the event flow saying why. A debuggable build already waives the whole integrity layer, and this is the same waiver applied consistently.
In practice:
| Build | Mock fixes |
|---|---|
| Debuggable | Stored, and uploaded with is_mock: true |
| Release | Dropped, unless you set mockLocationIntegrityPolicy(WARN) and mockLocationPolicy(MockPolicy.FLAG) deliberately |
isMock comes from the platform's own Location.isMock, which cannot be argued with. It is
Android-only.
19.3 Reading the result
when (val result = tracker.ready(config)) {
is TrackerResult.Error ->
if (result.code == ErrorCode.DEVICE_INTEGRITY_BLOCKED) {
// result.message names the blocking signals
val report = tracker.integrity()
showBlockedScreen(report.blockingSignals)
}
is TrackerResult.Ok -> Unit
}
// Live, and re-checked inside the health loop while a session is open.
tracker.integrityState()
.onEach { report -> banner.isVisible = report.findings.isNotEmpty() }
.launchIn(scope)
// Force a fresh evaluation — reads /proc, the package list and a loopback socket.
val fresh = tracker.checkIntegrity()
TrackerEvent.IntegrityChange is emitted when the flag set changes, not on every
evaluation. A BLOCK finding also arrives as TrackerEvent.Error with
ErrorCode.DEVICE_INTEGRITY_BLOCKED, and mid-session it ends the session.
IntegrityReport.waived is true in a debuggable build: nothing was probed, and the
empty findings list is not a claim that the device is clean.
19.4 On the wire and in storage
Every accepted point carries integrityFlags — the bitmask of every signal observed when
it was captured, WARN and BLOCK alike. It is persisted on the point, readable through
TrackPoint.integrityFlags, and uploaded by fieldtrack-sync:
{
"uuid": "…",
"is_mock": false,
"integrity_flags": 130,
"integrity_signals": ["DEVELOPER_MODE_ENABLED", "MOCK_LOCATION_APP_SELECTED"]
}
The bit assignments are frozen: ACCESSIBILITY_SERVICE_ACTIVE = 1, DEVELOPER_MODE_ENABLED
= 2, ADB_ENABLED = 4, HOOKING_FRAMEWORK_DETECTED = 8, DEBUGGER_ATTACHED = 16,
AUTO_TIME_DISABLED = 32, TIMEZONE_MISMATCH = 64, MOCK_LOCATION_APP_SELECTED = 128,
MOCK_LOCATION_FIX = 256, CLOCK_SKEWED = 512.
Both fields default, so a backend that has never seen them keeps parsing. 0 means
"nothing observed" — which is also what a debuggable build and a host with the layer
disabled send, so tell "clean" from "not evaluated" by the client version, not by this
column.
Evaluate server-side as well. These flags are advisory input to a server rule, never the whole defence: an attacker who has already hooked the process can patch the client that produces them. The value is that tampering has to defeat both sides.
19.5 Limits worth knowing
- Package visibility. From Android 11 the SDK cannot enumerate every installed app, so
MOCK_LOCATION_APP_SELECTEDcatches a fake-GPS app only where the platform makes it visible.QUERY_ALL_PACKAGESwould fix that and is deliberately not requested — it is a Play-policy declaration for every host, for a signalMOCK_LOCATION_FIXalready covers the moment a fake fix arrives. Add<queries>entries in your own manifest if you have a specific list you care about. - Client-side detection is not proof. It raises cost; it does not make spoofing impossible.
- The debuggable waiver is a real surface. A repackaged APK can set the flag — but re-signing changes the signing certificate, which is what the license token binds to.
- Emulators skip the Frida port scan. CI images run enough loopback tooling to make it noise.
19.6 Build-time checks
The SDK ships lint rules inside its AARs, so they run in your build:
| Issue | Severity | Fires on |
|---|---|---|
FieldTrackSecurityDisabled | fatal | securityEnabled(false) or IntegrityPolicy.ALLOW outside src/debug/ |
FieldTrackMockLocationAllowed | fatal | MockPolicy.ALLOW |
FieldTrackDebuggableRelease | fatal | android:debuggable="true" in the manifest |
FieldTrackLicenseHardcoded | warning | A license token written as a string literal |
Fatal issues fail assembleRelease through AGP's lintVital — which is the point: the
runtime layer waives itself in debug builds, so only the build can catch a release that
shipped with it switched off. Overrides belong in src/debug/, where the rules do not
fire and the runtime waiver already applies.
20. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ready() returns LICENSE_MISSING | Release build with no token | Supply the token via .license(...) in config (§2) |
start() returns NOT_READY | ready() not called or it failed | Check the TrackerResult from ready() |
start() returns PERMISSION_DENIED | No location permission | Walk the ladder in §4 |
start() returns PLAY_SERVICES_UNAVAILABLE | No Google Play Services | Set providerType = GPS_ONLY |
| Empty track, no points at all | NETWORK_ONLY with a tight accuracy ceiling | validate() rejects this — use AccuracyProfile.RELAXED or CUSTOM ≥ 50 m |
| Config changes do nothing | reset = false with a persisted config | Set reset = true (the default) during development |
| Very few points while stationary | Working as designed — the data-plane heartbeat warms the filter without storing | Set persistHeartbeat = true if you want them stored |
| Zigzag / drift while stationary | Accuracy ceiling too loose | AccuracyProfile.STRICT, or a CUSTOM ceiling |
| Corners drawn as straight chords | Turn fidelity settings off | Keep turnBurst = true, useGyroTurnPrediction = true, cornerAnchorCapture = true, bearingChangeCaptureDeg = 30; use smoothing = HEADING_SPLINE where the fixes carry a GNSS bearing |
| Navigation "randomly stops" | 1 Hz stream with no foreground service | navigationMode requires service.foregroundService — validate() enforces it |
| Tracking ends when the user swipes the app away | stopOnTerminate = true | Leave it false (the default) |
| Uploads retry forever | http:// URL blocked by Android's default network security policy | Use https://, or allowCleartext = true for a local dev server |
| Uploads stopped, rows still queued | A 403 halted the queue | Call sync.configure(...) again with a working credential |
| Queue does not drain when the network returns | autoSync = false, so only the durable half runs — nothing asks for a drain until the next enqueued work is released | Set autoSync = true, or call syncNow() from your own connectivity handling |
NetworkAvailable arrives but nothing uploads | The drain ran and failed — the event says a drain was requested, not that it succeeded | Read the HttpResponse that follows for the reason; a null statusCode means the request never completed |
| Backlog uploads in a scrambled order | Fixed — the queue is FIFO by insertion, including across a reboot | Update the SDK; older builds ordered on a monotonic clock that restarts at boot |
| Tracking stopped and the queue emptied | A 401 tore everything down | Re-authenticate, then ready() / start() / configure() again |
| The upload-status line vanished mid-session | A 401 or 403 cleared the sync config — the line is only posted while sync is configured | Check SyncEvent.HttpResponse for which, then the two rows above (§14.4) |
| The upload-status line never appeared | showSyncStatusInNotification left off, or configure() never called | Turn the flag on and configure sync; it is a diagnostic and stays off by default (§5.5) |
{pending} shown literally on the notification | A typo'd token in syncNotificationText | Only {pending} and {age} are substituted; an unknown token is left as written on purpose |
| The unsynced count looks frozen | It refreshes on the watchdogIntervalMs tick, and a parked device queues nothing | Read {age} alongside it — a still count with a rising age is a real stall, a still count with a resetting age is not |
SNAP_UNAVAILABLE in warnings | Your snap provider could not answer | Never fatal — the raw track is drawn. Check the OSRM server |
MOTION_DETECTION_DEGRADED | motionQuality = POOR on this hardware | Capture is forced to CONTINUOUS; expect more battery use (§12.1) |
MOTION_ONLY behaves like CONTINUOUS, battery high | motionQuality = POOR — the mode was overridden at ready() | Read tracker.state.value.effectiveTrackingMode. Check ACTIVITY_RECOGNITION is granted: a denial reaches POOR on hardware that is otherwise fine, and re-running ready() after the grant clears it |
Never saw MOTION_DETECTION_DEGRADED on a device you know is degraded | It is emitted inside ready(), and events has replay = 0 | Read TrackerState.motionQuality instead — it always has a current value. Collect events before calling ready() if you want the event itself |
| Stops reported minutes late | motionQuality = DEGRADED — the SDK doubled stopTimeoutMin | Working as designed on hardware with no gyroscope or trigger sensor. The Diagnostic at ready() names the old and new value |
ready()/start() returns DEVICE_INTEGRITY_BLOCKED | A BLOCK-policy signal fired | Read tracker.integrity() for the signals (§19); relax that policy to WARN if the device is legitimate |
Session ends by itself with DEVICE_INTEGRITY_BLOCKED | The health-loop re-check fired mid-session | Same as above; integrityRecheckIntervalMs(0) disables the periodic re-check |
| Integrity findings never appear | The host app is debuggable, so the layer is waived | Expected. Check IntegrityReport.waived; exercise the layer in a release build |
assembleRelease fails on FieldTrackSecurityDisabled | A release source set disables the integrity layer | Move the override to src/debug/ (§19.6) |
| Live map jumps backwards | Drawing a stale frame | Drop any LiveTrackUpdate whose sequence is not newer than the last drawn |
Try it before you buy
A 30-day trial licence for one application, issued instantly. Development builds are licence-waived, so you can evaluate the whole SDK first.
Get a trial keyVerification API
The SDK handles licensing for you. This is documented for tooling.
POST https://sdk.fieldtrack360.com/api/v1/verify