Android developer guide · FieldTrack360
FieldTrack360

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.

Published from the Android SDK's README, so it matches the release. View the source

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 groupcom.github.fieldtrack360.fieldtrack
DistributionJitPack (https://jitpack.io)
minSdk26 (Android 8.0)
compileSdk / targetSdk36
Java bytecode11 — loads on any JDK 11+ toolchain
Kotlin2.1.x
Host baselineAGP 8.x · Kotlin 2.0+ · React Native 0.81+ compatible

Table of contents

  1. Install
  2. License token
  3. Quick start
  4. Permissions
  5. Configuration reference
  6. Public API — Tracker
  7. Events, state and callbacks
  8. Data models
  9. Plotting and export
  10. Live tracking
  11. Geofences
  12. Battery and sensors
  13. Maps module
  14. Sync module — upload to your backend
  15. Log module — diagnostics to your backend
  16. Snap module — road matching
  17. Diagnostics
  18. Java interop
  19. ProGuard / R8
  20. Device integrity
  21. 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]
fieldtrack = "<version>"

[libraries]
fieldtrack = { 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 §16).

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.

That OkHttp is version 5, and it will win over an older one already in your build — Gradle resolves a conflict by taking the highest version. Upgrading okhttp on its own is not always safe, because OkHttp 5 deleted internal classes that OkHttp 4 siblings call. The one that bites in practice is okhttp-urlconnection: React Native ships it at 4.x for its cookie jar, and 4.x JavaNetCookieJar calls the removed okhttp3.internal.Util, so the first cookie-bearing request dies with

java.lang.NoClassDefFoundError: Failed resolution of: Lokhttp3/internal/Util;
    at okhttp3.JavaNetCookieJar.decodeHeaderAsJavaNetCookies(JavaNetCookieJar.kt:81)

fieldtrack-core publishes a constraint that pulls okhttp-urlconnection up to match, so an ordinary Gradle build needs nothing from you. If your build forces OkHttp versions itself — React Native's Gradle plugin is able to — a force beats our constraint and you have to align the family yourself:

implementation(platform("com.squareup.okhttp3:okhttp-bom:5.1.0"))

The rule is the same either way: move every com.squareup.okhttp3 artifact together, never one alone.

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_OPTIMIZATIONS is deliberately not declared — it is Play-policy sensitive and must be your own explicit choice.

  • No ProGuard rules. consumer-rules.pro ships 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 shipYour project needs
Kotlin 2.1.x metadataKotlin 2.0 or newer
compileSdk 36compileSdk 36 or newer
Java 11 bytecodeAny JDK 11+ toolchain (JDK 17 recommended)
AGP 8.x metadataAGP 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.

Strongly recommended for every host, and it is a manifest block with no Kotlin behind it. Add this inside <application> in your app's AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>

        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="androidx.work.WorkManagerInitializer"
                android:value="androidx.startup"
                tools:node="remove" />
        </provider>

    </application>
</manifest>

Why

WorkManager installs itself through androidx.startup's ContentProvider, which Android runs during Application attach — before Application.onCreate, on the main thread, on every cold start of your process.

For most apps that is only a slow start. For this SDK it is a crash risk. TrackingService is started with startForegroundService(), and the platform's ~10 second start-foreground deadline opens at that call, not when the service runs. The process very often starts cold in answer to it — a sticky restart, a boot resume, an OEM revival — so every ContentProvider in the merged manifest runs inside that window before the service is even constructed. Overrun it and the platform kills the app with:

Fatal Exception: android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException
Context.startForegroundService() did not then call Service.startForeground()

That crash is thrown by the system on your main looper. The SDK cannot catch it. Every millisecond removed from Application attach is budget handed back to the promotion.

You do not need Configuration.Provider

Removing that initializer normally forces your Application to implement androidx.work.Configuration.Provider, because WorkManager.getInstance() throws once nothing has initialised it. With this SDK it does not: the SDK routes every handle through its own accessor, which initialises WorkManager with the identical default configuration the first time it needs one — off the cold-start path, on whatever thread asked.

The one case where you should not do this

If your own app code calls WorkManager.getInstance() — you schedule your own workers — then whichever of you touches WorkManager first wins, and your call can throw. In that case either leave the block out, or add it and implement Configuration.Provider on your Application the way AndroidX documents. Both are safe; the SDK works either way.

This is why the SDK does not ship the removal in its own merged manifest: deleting a dependency's initializer out from under a host would break hosts that were relying on it.

sample-android/src/main/AndroidManifest.xml carries the block as a worked reference.


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.md explains 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
FIELDTRACK_LICENSE=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.

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

MemberTypeMeaning
statusLicenseStatusACTIVE, REVOKED, EXPIRED, UNKNOWN_KEY, INVALID_KEY, PACKAGE_MISMATCH, SDK_MISMATCH, UNRECOGNISED
validBooleanThe server's own flag. Branch on status, not this
packageNameStringThe application id the licence was issued against
checkedAtStringISO-8601, the server's clock, verbatim
ttlSecondsLongHow long this answer may keep being trusted
reasonString?The server's explanation, when it sent one
fromCacheBooleantrue 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 an Activity leaks nothing. It is cheap: no database is opened and no disk touched until ready().
  • Nothing on the Tracker surface throws. Every fallible call returns TrackerResult with a typed ErrorCode. The only deliberate exceptions are TrackerConfig.Builder.build() and SyncConfig.Builder.build(), which fail fast with IllegalArgumentException on your own thread while you assemble the value. Use buildUnchecked() + validate() if you prefer to read the errors yourself.
  • ready() must be called before start() or getCurrentLocation(); otherwise you get ErrorCode.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

MethodReturnsNotes
tier()PermissionTierNONE, FOREGROUND_ONLY or FULL
accuracy()LocationAccuracyPRECISE when ACCESS_FINE_LOCATION is granted, else APPROXIMATE
hasActivityRecognition()BooleanAlways true below API 29
hasNotificationPermission()BooleanAlways 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()BackgroundRequestStep 2 — what to do next for FULL
shouldStopAsking(attempts: Int)Booleantrue at 3 attempts — stop prompt-looping
appSettingsIntent()IntentDeep 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:

CaseMeaning
AlreadyGrantedNothing to do
NotApplicableBelow API 29 — no separate background permission exists
NeedsForegroundFirstAsk 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() and getCurrentLocation() return ErrorCode.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

FieldTypeDefaultWhat it does
geolocationGeolocationConfigdefaultsProvider, accuracy, cadence
motionMotionConfigdefaultsActivity recognition, stop detection, heartbeat
serviceServiceConfigdefaultsForeground service, notification, survival
persistencePersistenceConfigdefaultsRetention and diagnostic storage
sensorsSensorConfigdefaultsHardware motion assists
securitySecurityConfigdefaultsDevice-integrity policy — accessibility, developer mode, hooking frameworks, clock tampering, mock-location apps (§20). Waived entirely in debuggable builds
licenseString?nullRelease license token. Never persisted
baseUrlString?nullScheme + host for uploads, e.g. https://api.example.com. Core never opens a socket; fieldtrack-sync resolves a relative path against it
resetBooleantruetrue — 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

FieldTypeDefaultWhat it does
trackingModeTrackingModeADAPTIVESee below. Can be overridden at ready() on hardware that cannot detect motion — §12.1
providerTypeLocationProviderTypeFUSEDWhich hardware produces fixes
desiredAccuracyDesiredAccuracyHIGHBiases the fused provider's own source choice. HIGH, BALANCED, LOW
accuracyAccuracyConfigBALANCED profileThe accuracy meter — see §5.6
distanceFilterMFloat0fMust stay 0. A non-zero OS distance filter generates stationary drift; all thinning is done in software
intervalMsLong60_000Requested sampling interval
fastestIntervalMsLong30_000Fastest the OS may deliver. Must be ≤ intervalMs
maxUpdateDelayMsLong60_000OS batching window
maxFixAgeMsLong10_000Older fixes are treated as stale
deliveryStalenessMsLong60_000Delivery-gap threshold
adaptiveCadenceBooleantrueSpeed up while vehicular. Off pins every moving fix to intervalMs
vehicularIntervalMsLong12_000The 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
turnBurstBooleantrueThird tier: sample faster while measurably turning
turnBurstIntervalMsLong4_000Must be > 0 and ≤ the tier it accelerates
oneShotTimeoutMsLong30_000getCurrentLocation() timeout
mockLocationPolicyMockPolicyFLAGFLAG (store + mark), REJECT, ALLOW
navigationModeBooleanfalse~1 Hz high-accuracy profile that overrides every adaptive tier. Requires service.foregroundService
navigationIntervalMsLong1_000Navigation interval
navigationFastestIntervalMsLong500Navigation floor
stationaryIntervalMsLong300_000Parked tier, ADAPTIVE only: the stream's interval once the device is parked (at STATIONARY, or after stopTimeoutMin with no movement since the session opened). OS batching is off on this tier. Capped at half of deadTrackerStationaryMin. A value ≤ intervalMs — 0 included — keeps the base interval while parked (the old behaviour)
stationaryAccuracyDesiredAccuracy?nullPriority of the parked-tier request. null keeps desiredAccuracy. BALANCED saves more but feeds the stationary filter Wi-Fi/cell centroids that hop indoors

TrackingMode

ValueBehaviour
CONTINUOUSStream at intervalMs always; the filter does all thinning. Highest fidelity, highest battery
ADAPTIVEStream while moving with adaptive cadence; heartbeat-only while stationary (default)
ADAPTIVEStream while moving with adaptive cadence; slowed to stationaryIntervalMs while parked, with the filter keeping only the heartbeat (default)
MOTION_ONLYLocation fully off while stationary. Lowest battery, coarsest stop timing

When each tier is in force (fastest wins: navigation → turn burst → vehicular → base): When each tier is in force (fastest wins: navigation → turn burst → vehicular → base → parked):

TierRaised byDropped by
vehicularA 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 vehicularSTOP_PENDING — the moment fixes stop reporting movement, not stopTimeoutMin later
turn burstTurnDetector (GNSS heading) or GyroTurnMonitor (yaw rate), whichever sees the corner firstBoth releasing it, or a stop
parked (ADAPTIVE only)STATIONARY; or stopTimeoutMin with no movement since the session opened (a session started, or revived after an OEM kill, at a desk never reaches STATIONARY)MOVING — significant motion, activity recognition, the stationary geofence, or a parked fix that reads as moving. Any faster tier also outranks it while in force

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 battery difference between the three modes is mostly whether the location stream keeps sampling while the device is parked. CONTINUOUS keeps it at intervalMs. ADAPTIVE keeps it registered but slowed to stationaryIntervalMs (five minutes by default), because the heartbeat is what recovers a device whose wake paths all failed — the cost is that such a device is noticed up to one parked interval late. MOTION_ONLY genuinely unregisters it. A phone parked for eight hours costs a full day of receiver duty on the first, a fraction of that on the second, and near zero on the third. Before the parked tier existed, ADAPTIVE cost the same as CONTINUOUS while parked.

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

ValueBehaviour
FUSEDPlay Services fused provider. Blends GNSS, Wi-Fi, cell and sensors. Best time-to-first-fix. Default
GPS_ONLYLocationManager.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_ONLYWi-Fi/cell centroids. Coarse (20–2000 m), cheap. Needs an accuracy ceiling ≥ 50 m — validate() rejects a tighter one
PASSIVEFixes 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(). .navigationIntervalMs(), .navigationFastestIntervalMs(), .stationaryIntervalMs(), .stationaryAccuracy(), .oneShotTimeoutMs(), .mockLocationPolicy().

5.3 MotionConfig

FieldTypeDefaultWhat it does
activityRecognitionBooleantrueUse Play Services activity recognition as enrichment
activityRecognitionIntervalMsLong10_000AR polling interval
activityConfidenceMinInt75Minimum confidence for a transition
snapshotConfidenceMinInt50Minimum confidence for a snapshot read
suppressWhileStationaryBooleanfalseDrop points that only stationary drift explains, when the accelerometer agrees the device has not moved — see §12.3. A veto, never a trigger: it can only remove a point the pipeline already read as stationary, is not consulted once displacement or Doppler read as moving, and one counted step withdraws it. Needs an accelerometer; turned off with a Diagnostic where there is none
stillnessEscapeMinInt30How long suppressWhileStationary may suppress before letting one fix through regardless. The safety valve — a wedged accelerometer degrades to the previous behaviour instead of silencing a shift
stopTimeoutMinInt5Minutes of no movement before STATIONARY
stationaryRadiusMFloat150fRadius of the internal stationary wake geofence. Must be > 0
stationaryGeofenceIdString"trackit-stationary"Id of that fence. Must not be blank
stationaryGeofenceOnEnterEventString"stationary_fence_enter"Event name on enter
stationaryGeofenceOnExitEventString"stationary_fence_exit"Event name on exit
motionTriggerDelayMsLong0Delay before acting on a motion trigger
heartbeatIntervalSecInt900Data-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
persistHeartbeatBooleanfalseAlso store the heartbeat point
bearingChangeCaptureDegInt30Store 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
cornerAnchorCaptureBooleantrueRestore 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(), .suppressWhileStationary(), .stillnessEscapeMin(), .stopTimeoutMin(), .stationaryRadiusM(), .stationaryGeofenceId(), .stationaryGeofenceOnEnterEvent(), .stationaryGeofenceOnExitEvent(), .motionTriggerDelayMs(), .heartbeatIntervalSec(), .persistHeartbeat(), .bearingChangeCaptureDeg(), .cornerAnchorCapture().

5.4 SensorConfig

FieldTypeDefaultWhat it does
useSignificantMotionBooleantruePermission-free, ~zero-power hardware wake for STATIONARY → MOVING
useStepCorroborationBooleantrueStep-count veto on stationary drift; confirms indoor walks
useBarometerBooleanfalseUse pressure sensor when present
stepBatchLatencyMsLong60_000Step-counter batching latency
useGyroTurnPredictionBooleantrueArm 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(), .useGyroTurnPrediction(), .useBarometer(), .stepBatchLatencyMs().

5.5 ServiceConfig

FieldTypeDefaultWhat it does
foregroundServiceBooleantrueRun capture in a foreground service
stopOnTerminateBooleanfalseInverted from the common default on purpose — a swipe-away does not silently end tracking
startOnBootBooleantrueResume an open session after reboot / app update
healthLoopMsLong120_000Supervision loop period
watchdogIntervalMsLong60_000Watchdog check period
watchdogThrottleMsLong900_000Minimum gap between watchdog restarts
backstopIntervalMinInt15WorkManager backstop period
deadTrackerMovingMinInt30Minutes with no fix while moving before declaring the tracker dead
deadTrackerStationaryMinInt60Same, while stationary
wakeLockMsLong20_000Wake-lock hold during a capture burst
wakeLockMsLong20_000Wake-lock hold per delivered fix (WakeLockPolicy.PER_FIX), capped at a third of the fix's cadence tier (never below 2 s) — 4 s at the 12 s vehicular tier, 5 s at a 15 s base. Without the cap any tier faster than the hold kept the CPU awake continuously. One-shot and backstop fixes keep the full value
notificationTitleString"Tracking active"Foreground notification title. Never overridden — the sync diagnostic below cannot take this slot
notificationTextString"Recording your location"Foreground notification body
notificationChannelIdString"trackit_tracking"Channel id
notificationChannelNameString"Location tracking"Channel name shown in system settings
notificationSmallIconResNameString?nullDrawable resource name (e.g. "ic_tracking") for the small icon
showRecoveryNotificationBooleantrueOn FGS_START_REFUSED with a session open, post a "tap to resume" notification. The tap restarts the service in the same session (legal on API 31+, unlike background retries). A swipe silences it until the session ends
recoveryNotificationTitleString"Tracking paused"Tap-to-resume title
recoveryNotificationTextString"Tap to resume location tracking"Tap-to-resume body
recoveryNotificationChannelIdString"trackit_recovery"Channel id (IMPORTANCE_DEFAULT, separate from the quiet ongoing channel)
recoveryNotificationChannelNameString"Tracking alerts"Channel name shown in system settings
showSyncStatusInNotificationBooleanfalseDiagnostic — 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
syncNotificationSubTextString?nullThe subtitle shown beside notificationTitle while the sync line is on screen. null = no subtitle. Rendered with setSubText; never replaces the title
syncNotificationTextString"unsynced {pending} · last upload {age}"The sync line template. See the token table below. Ignored unless showSyncStatusInNotification is on
powerReportIntervalMinInt15Emit a PowerReported event this often during a session: battery drain (charge counter and %), screen-on time, and the SDK's own fixes, wake-lock time, one-shots, remote wakes and cadence tier for the same window. Recorded by fieldtrack-sync's log channel as tag Power, code POWER_REPORT. 0 disables

Builder: .foregroundService(), .stopOnTerminate(), .startOnBoot(), .healthLoopMs(), .watchdogIntervalMs(), .watchdogThrottleMs(), .backstopIntervalMin(), .deadTrackerMovingMin(), .deadTrackerStationaryMin(), .wakeLockMs(), .notification(title, text), .notificationChannel(id, name), .notificationSmallIconResName(), .showSyncStatusInNotification(), .syncNotification(subText, text). .showRecoveryNotification(), .recoveryNotification(title, text), .recoveryNotificationChannel(id, name), .showSyncStatusInNotification(), .syncNotification(subText, text), .powerReportIntervalMin().

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:

TokenBecomes
{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 watchdogIntervalMs tick, 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 terminal 401 / 403 clears the config mid-session — the subtitle disappears and the body reverts to your own notificationText. 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.

FieldTypeDefaultNotes
profileAccuracyProfileBALANCEDNamed ceiling
maxAccuracyMetersFloat?nullRequired by CUSTOM, rejected by every other profile
recoveryTrustMetersFloat?nullOverrides the profile's post-gap re-anchor bar. Must be > 0
ProfileMoving ceilingRe-anchor barUse when
STRICT20 m15 mUrban canyon; sparser track, no zigzag
BALANCED30 m25 mDefault, set from field data
RELAXED60 m40 mIndoor / network-assisted / coverage-first
CUSTOMmaxAccuracyMeters (5–500 m)25 m unless overriddenYou 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

FieldTypeDefaultWhat it does
maxDaysToPersistInt7TTL for stored points. 0 = unlimited. Must be ≥ 0
maxRecordsInt0Row cap. 0 = unlimited
persistRawFixesBooleanfalseStore fixes exactly as the OS delivered them (debug layer 1)
rawRingCapacityInt5_000Ring size for raw fixes
persistRawPointsBooleanfalseStore every judged fix in point form, accepted or not (debug layer 2). One wide row per fix — real write amplification
rawPointRingCapacityInt20_000Rows kept per session, not globally
persistDecisionsBooleantrueKeep the decision log
decisionRetentionDaysInt3Decision log TTL
decisionMaxRowsInt50_000Decision log row cap

Builder: .maxDaysToPersist(), .maxRecords(), .persistRawFixes(), .rawRingCapacity(), .persistRawPoints(), .rawPointRingCapacity(), .persistDecisions(), .decisionRetentionDays(), .decisionMaxRows().

5.8 Validation rules

validate() (and therefore build() / ready()) rejects:

  • intervalMs < fastestIntervalMs
  • distanceFilterM > 0
  • heartbeatIntervalSec < 5 × (intervalMs / 1000)
  • stationaryRadiusM <= 0, blank stationaryGeofenceId / enter / exit event names
  • stillnessEscapeMin <= 0 while suppressWhileStationary is on — checked only when the stage is running, since the bound is a safety valve and zero would mean "never expire"
  • baseUrl that is not an absolute URL with a scheme and host
  • turnBurstIntervalMs <= 0, or greater than the tier it accelerates
  • navigationIntervalMs <= 0, navigationIntervalMs < navigationFastestIntervalMs, or navigationMode without foregroundService
  • maxDaysToPersist < 0
  • stationaryIntervalMs < 0 or powerReportIntervalMin < 0 (a parked interval at or below intervalMs is legal and simply leaves the tier off)
  • maxAccuracyMeters set without CUSTOM, missing with CUSTOM, or outside 5–500 m
  • recoveryTrustMeters <= 0
  • NETWORK_ONLY with an accuracy ceiling below 50 m
  • navigationMode with providerType = PASSIVE
  • blank syncNotificationText while showSyncStatusInNotification is on — checked only when the line will actually be posted, so leaving the diagnostic off is never refused over a string nothing reads
  • syncNotificationSubText set to a blank string — use null for no subtitle

6. Public API — Tracker

val tracker = Tracker.getInstance(context)   // @JvmStatic, idempotent, thread-safe

6.1 Lifecycle

MethodSignatureNotes
readysuspend 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
startsuspend fun start(tag: String? = null): TrackerResult<TrackSession>Opens a session. NOT_READY if ready() was not called
stopsuspend fun stop(): TrackerResult<TrackSession?>Closes the open session
stateval state: StateFlow<TrackerState>Coarse lifecycle state
eventsval events: SharedFlow<TrackerEvent>Replay 0, unlimited subscribers
wake@JvmStatic @WorkerThread fun Tracker.wake(context: Context): WakeResultStatic, on the companion. Remote wake for a high-priority FCM message — forces a fix and an upload if the service is alive, restarts it if a session is open and the service is dead, does nothing otherwise. Safe in a cold process and before ready(). See §12.4
wake@JvmStatic @WorkerThread fun Tracker.wake(context: Context): WakeResultStatic, on the companion. Remote wake for a high-priority FCM message — requests an upload, plus a forced fix unless the stream is already delivering, if the service is alive; restarts it if a session is open and the service is dead, does nothing otherwise. Safe in a cold process and before ready(). See §12.4

6.2 Location

MethodSignatureNotes
getCurrentLocationsuspend 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
providerStatefun providerState(): StateFlow<ProviderState>GPS toggle, permission tier, granularity, fused availability, battery saver. Broadcast-driven, never polled
permissionTierfun permissionTier(): PermissionTier
permissionsfun permissions(): PermissionManagerThe permission ladder as data
offerFixfun 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

MethodSignature
getPointssuspend fun getPoints(query: PointQuery = PointQuery()): List<TrackPoint>
observePointsfun observePoints(sessionId: String): Flow<List<TrackPoint>>
getCountsuspend fun getCount(query: PointQuery = PointQuery()): Int
getOdometerMeterssuspend fun getOdometerMeters(): Double
getSessionssuspend fun getSessions(fromMs: Long? = null, toMs: Long? = null): List<TrackSession>
currentSessionsuspend fun currentSession(): TrackSession?

All reads are paged — PointQuery(limit = 500, offset = 0) by default.

6.4 Plotting

MethodSignature
buildTracksuspend fun buildTrack(query: PointQuery = PointQuery(), options: TrackOptions = TrackOptions()): Track
exportPolylineJsonsuspend fun exportPolylineJson(query, options): String
exportGeoJsonsuspend fun exportGeoJson(query, options): String
setRoadSnapProviderfun setRoadSnapProvider(provider: RoadSnapProvider)

6.5 Live tracking

MethodSignature
liveTrackfun liveTrack(): Flow<LiveTrackUpdate>
setActiveRoutefun setActiveRoute(route: List<GeoPoint>)
isOffRoutefun isOffRoute(): Boolean

6.6 Geofences

MethodSignature
addGeofencesuspend fun addGeofence(geofence: TrackerGeofence): TrackerResult<TrackerGeofence>
removeGeofencesuspend fun removeGeofence(id: String = TrackerGeofence.DEFAULT_ID): TrackerResult<Boolean>
removeAllGeofencessuspend fun removeAllGeofences(): TrackerResult<Int>
getGeofencefun getGeofence(id: String = TrackerGeofence.DEFAULT_ID): TrackerGeofence?
getGeofencesfun getGeofences(): List<TrackerGeofence>
getGeofenceEventsfun getGeofenceEvents(geofenceId: String? = null, fromMs: Long? = null, toMs: Long? = null, limit: Int = 500, offset: Int = 0): List<TrackerGeofenceEvent>
deleteGeofenceEventsfun deleteGeofenceEvents(geofenceId: String? = null, fromMs: Long? = null, toMs: Long? = null): Int

6.7 Device state

MethodSignature
batteryInfofun batteryInfo(): BatteryInfo
batteryStatefun batteryState(): StateFlow<BatteryInfo>
getSensorsfun getSensors(): DeviceSensors

6.8 Diagnostics

MethodSignature
getRawFixessuspend fun getRawFixes(sessionId: String): List<RawFix>
getRawPointssuspend fun getRawPoints(sessionId: String): List<RawPoint>
getDecisionssuspend 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)
        }
    }
}
EventPayloadFires when
Locationpoint: TrackPointA fix was accepted and stored
LocationRejecteddecision: FixDecisionA fix was skipped or rejected, with the numeric reason
MotionChangestate: MotionState, point: TrackPoint?STOPPED ⇄ MOVING ⇄ STOP_PENDING ⇄ STATIONARY
ActivityChangeactivity: ActivityType, confidence: IntActivity recognition transition
EnabledChangeenabled: BooleanLocation services toggled
ProviderChangestate: ProviderState, previous: ProviderState?GPS toggle, permission change, granularity change, battery saver. previous is the snapshot this one replaced — diff the two to find which field moved. null on the first reading after ready(), which is a starting position rather than a transition
PermissionChangeprevious: PermissionTier, current: PermissionTier, accuracy: LocationAccuracyThe location grant moved, in either direction — revoke, re-grant, all-the-time→while-using, precise→approximate
LocationServicesChangeenabled: Boolean, state: ProviderStateThe GPS/location master switch was toggled. Both directions, including the recovery
CaptureSuspendedreason: ErrorCode, message: StringCapture stopped but the session is still open: permission revoked, or every provider off
CaptureResumed—Capture re-armed in the same session after a CaptureSuspended
HeartbeatatMs: LongControl-plane liveness tick (distinct from the data-plane heartbeat)
PowerSaveChangeenabled: BooleanBattery saver on/off
BatteryChangebattery: BatteryInfoPlug, unplug, low, okay — and drift the capture path notices
PowerReportedreport: PowerReportEvery powerReportIntervalMin during a session: where the battery went. Device side — batteryPercent, batteryPercentDrop, drainMa (charge counter), drainPercentPerHour, pluggedDuringWindow, screenOnMs, powerSaveMode. SDK side — fixesDelivered, wakeLockHeldMs, oneShotFixes, remoteWakes, remoteWakeOneShotsSkipped, cadenceMs, parkedTier, motionState. Drain figures are null when unmeasurable (plugged in, unsupported counter, window under 5 min)
GeofenceAdded / GeofenceRemovedgeofence / geofenceIdRegistry changed
GeofenceEntered / GeofenceExitedgeofence: TrackerGeofenceA fence was crossed
IntegrityChangereport: IntegrityReportThe device-integrity flag set changed — transitions only, not every check (§20)
SessionInterruptedsession: TrackSessionready() found a session left open by a crash or force-stop — you decide what to do
Diagnosticmessage: StringInformational
Errorcode: ErrorCode, message: StringAnything 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,
)
FieldRead it for
isTracking vs isCapturingA 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
motionQualityWhether 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)
effectiveTrackingModeThe 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,
    val locationPowerSaveMode: LocationPowerSaveMode = LocationPowerSaveMode.NO_CHANGE,
)

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 locationPowerSaveMode is what battery saver does to location (PowerManager.getLocationPowerSaveMode()), and it is the OEM's choice: NO_CHANGE, GPS_DISABLED_WHEN_SCREEN_OFF, ALL_DISABLED_WHEN_SCREEN_OFF, FOREGROUND_ONLY, THROTTLE_REQUESTS_WHEN_SCREEN_OFF, or UNKNOWN (battery saver on below API 28). Always NO_CHANGE while powerSaveMode is off. The two screen-off "disabled" modes stop or cut background tracking for as long as the screen is off. The SDK cannot override them, so ask the user to turn battery saver off during a shift. The log channel records the mode on every DEVICE_LOCATION and provider line, and logs those two modes at WARN.

All three 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>
}
ErrorCodeMeaning
NOT_READYready() has not been called
PERMISSION_DENIEDNo location permission at all
BACKGROUND_PERMISSION_MISSINGBackground location needed for the requested behaviour
COARSE_ONLYApproximate-only access defeats the pipeline's gates
LOCATION_DISABLEDGPS and network providers both off
PLAY_SERVICES_UNAVAILABLEFused provider unavailable — switch to GPS_ONLY
FGS_START_REFUSEDThe OS refused the foreground service start
FGS_START_REFUSEDThe OS refused the foreground service start. With a session open the SDK posts a tap-to-resume notification (showRecoveryNotification)
NOTIFICATION_HIDDENThe foreground notification is not visible
FIX_TIMEOUTNo usable fix within oneShotTimeoutMs
STORAGE_FULLNo room to persist
STORAGE_RESETThe store had to be reset
TRACKER_DEADNo fix for deadTrackerMovingMin / deadTrackerStationaryMin
INVALID_CONFIGvalidate() reported errors
LICENSE_MISSING / LICENSE_INVALID / LICENSE_BUNDLE_MISMATCHOffline license gate, in ready()
LICENSE_REVOKED / LICENSE_EXPIREDOnline check. Stops tracking
LICENSE_UNKNOWN / LICENSE_PACKAGE_MISMATCH / LICENSE_SDK_MISMATCHOnline check. Diagnostic only — tracking continues
NO_ACTIVITYAn Activity was required and none supplied
MOTION_DETECTION_DEGRADEDmotionQuality = POOR — motion gating is untrustworthy on this hardware
GEOFENCE_REGISTRATION_FAILED / GEOFENCE_REMOVAL_FAILED / GEOFENCE_LIMIT_REACHEDGeofence operations
SNAP_UNAVAILABLEA RoadSnapProvider could not answer. Never fatal — the track is built from raw geometry with a snap_unavailable warning
INTERNALSomething 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 §20.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

TypeValues
MovementStatusSTEADY, MOVING
MotionStateSTOPPED, MOVING, STOP_PENDING, STATIONARY
ActivityTypeIN_VEHICLE, ON_BICYCLE, ON_FOOT, WALKING, RUNNING, STILL, TILTING, UNKNOWN (+ isLowTier)
MockPolicyFLAG (default), REJECT, ALLOW
PermissionTierNONE, FOREGROUND_ONLY, FULL
LocationAccuracyAPPROXIMATE, PRECISE
PowerSourceNONE, AC, USB, WIRELESS, DOCK, UNKNOWN
MotionQualityFULL, DEGRADED, POOR
GeofenceTransitionENTER, EXIT
WakeResultALIVE, REVIVED, REFUSED, NO_SESSION, DISABLED, DISPATCHED, TIMED_OUT — see §12.4
WakeResultALIVE, REVIVED, REFUSED, NO_SESSION, DISABLED, DISPATCHED, TIMED_OUT, LOCATION_OFF — see §12.4

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

FieldTypeDefaultWhat it does
zoomFloat14fSelects the arrow spacing tier
includeRawPointsBooleantrueEmit the points array
consolidateStopsBooleantrueCollapse dwell clusters into stop nodes
stopRadiusMDouble60.0Cluster radius for a stop
stopMinDwellSecLong600Minimum dwell to count as a stop
smoothingSmoothingSPLINENONE, BEZIER, SPLINE, HEADING_SPLINE
splineSpacingMDouble5.0Resample spacing for SPLINE
bezierMinAngleDegDouble30.0Only rounds vertices sharper than this (BEZIER)
bezierCutbackMDouble25.0Corner cutback distance (BEZIER)
snapToRoadBooleantrueUse road geometry if a provider is installed. Costs nothing with no provider
snapMaxOffRoadMDouble80.0Beyond this from the returned road, a fix keeps its captured position
snapMaxDetourFactorDouble2.5How much longer than its chord an injected road span may be. Double.POSITIVE_INFINITY disables the bound
snapBridgeFlatMDouble200.0Flat allowance under snapMaxDetourFactor; carries a junction or roundabout whose chord is near zero
polylinePrecisionInt6Encoded-polyline precision
speedBandsKmphList<Float>[10f, 20f]Thresholds for per-segment speed bands
arrowMinSegmentMDouble60.0Minimum segment length to place an arrow
simplifyEpsilonMDouble2.0Douglas-Peucker tolerance before smoothing. 0 disables

Smoothing

ValueBehaviour
NONEChords between stored vertices, exactly as captured
BEZIERRound vertices sharper than bezierMinAngleDeg; every leg stays a chord
SPLINECentripetal Catmull-Rom through every vertex, resampled. Default — a 120 m leg becomes a curve, not a chord
HEADING_SPLINEAs 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 = "fieldtrack-stationary", DEFAULT_ENTER_EVENT, DEFAULT_EXIT_EVENT.

Do not reuse DEFAULT_ID (or your motion.stationaryGeofenceId) for a host fence. That id is the SDK's stationary wake fence: an EXIT on it is how a parked, killed process learns the user has driven off. The SDK recognises it by id even when its own bookkeeping has lost the registration, so a host fence under the same id would be treated as the wake fence too.


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

MotionQualityDerived whenWhat the SDK does about it
FULLAccelerometer and gyroscope and (significant-motion or step-detector)Nothing — your config runs as written
DEGRADEDAnything between the twomotion.stopTimeoutMin is doubled, mode untouched. A Diagnostic event names the old and new value
POORNo accelerometer, or ACTIVITY_RECOGNITION denied with no significant-motion and no step-detectortrackingMode 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.

12.3 Points from a device that is not moving

The oldest complaint in location tracking: the phone is on a desk, nothing is moving, and points keep arriving.

Every stationary defence in the acceptance pipeline reasons about position, because a GNSS fix carries nothing else — wobble guards, the measurement-noise penalty, the net-displacement departure ladder. That makes them statistical, and a statistical gate needs an escape hatch wide enough for a real journey. Indoor multipath is very good at finding those hatches: served by fused location, a still phone hops between Wi-Fi and cell centroids, and each hop is a plausible-looking displacement with a respectable accuracy circle.

Two things address it, and they are independent.

The pipeline itself was tightened, and this needs no configuration. A single fix could confirm a departure on net displacement alone, and confirming one latches movingMode, which switches the departure ladder off until the filter settles — so one 160 m centroid hop bought a licence for every hop after it. Size now only confirms a departure while the GNSS chip reports some motion; where it reports none, displacement is the only evidence in play and its size proves nothing, because drift is always able to be a long way from the anchor. A second fix closed the tally surviving the drift back to the anchor, which let two hops minutes apart satisfy a test written to require two consecutive advancing fixes.

motion.suppressWhileStationary adds a witness of a different kind, and is off by default. The accelerometer measures the device rather than its own estimate of itself, and nothing about a centroid hop can move it. Four conditions must agree before a single point is withheld:

ConditionWhy
You asked for itOff by default
The pipeline already read the fix as stationaryDisplacement that measured as travel outranks any sensor window
The GNSS chip reports no speedDoppler is a hardware measurement multipath cannot fabricate; a chip reporting motion wins
The pedometer counted no steps, or is absentOne step withdraws the veto

And it expires. After stillnessEscapeMin (30 minutes by default) the claim lapses for one fix, judged exactly as it would have been before the stage existed. That bound is not about accuracy — it is so that an accelerometer that stops delivering, or a threshold wrong for some OEM's part, costs a slow trickle of drift points rather than a silent shift.

Those limits are why it is safe to switch on. A motion API reporting STILL through a 17-minute drive is a documented failure on this SDK's target hardware (OnePlus and Xiaomi under battery saver), and every bound above exists so a wrong sensor costs a suppressed drift point and never a trip. This is also why it is a veto and never a trigger — nothing here can cause a point to be stored, and capture is never gated on motion detection.

Suppressed fixes appear in the decision log as Reasons.STILLNESS_VETO (§17.4), so if you think a real point went missing you can see exactly which fixes were withheld and why.

Requires an accelerometer. Without one the flag is turned off at ready() and a Diagnostic says so — the same hardware that reaches MotionQuality.POOR in §12.1.

12.4 Remote wake — FCM

A process that an OEM battery manager has frozen, or that Android has parked in Doze, uploads nothing and notices nothing. The device cannot see that gap, but your server can: an open shift with no upload for N minutes. The fix is for the server to wake the device.

Of the ways to wake an Android app, only a high-priority FCM data message gets through Doze. It is also one of the few things that lets an app start a foreground service from the background on API 31+. The SDK has no Firebase dependency. You own FCM, and you pass the message to the SDK:

class AppMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        if (message.data["type"] == "fieldtrack_wake") {
            Tracker.wake(applicationContext)   // already on a background thread
        }
    }

    override fun onNewToken(token: String) {
        // send to your backend against the signed-in user + device
    }
}
<service
    android:name=".AppMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

Tracker.wake() decides whether there is anything to do:

WakeResultMeaning
ALIVEThe service was running. A fix and an upload drain were requested
ALIVEThe service was running. An upload drain was requested, and a forced fix too — unless the stream had already delivered within its own interval, in which case the fix is skipped (the stream's own fix covers it; counted as remoteWakeOneShotsSkipped in PowerReported)
REVIVEDA session was open with no service behind it; the service was started
REFUSEDThe platform refused the start. The SDK's counted restore path is queued and retries with backoff
REFUSEDThe platform refused the start — at startForegroundService, or at the service's own startForeground inside the wake window. The SDK's counted restore path is queued and retries with backoff
LOCATION_OFFA session is open but location services are off. No fix was requested and no service started (a running one is left as it is and resumes capture by itself); queued uploads were still drained. Stop pushing this device until it next reports a point — a push cannot turn location back on
NO_SESSIONNo session is open. Nothing was started — a wake never starts a session
DISABLEDservice.foregroundService = false; nothing to start
DISPATCHEDCalled on the main thread, so it ran in the background instead of blocking
TIMED_OUTThe session lookup did not finish within ~8 s; nothing was started

It blocks for one database read (bounded), so call it off the main thread; onMessageReceived already runs off it. Each call is also written to the diagnostic log as a Tracker line, remote wake: <RESULT> (§15.5). That line is how you tell whether a push reached the device at all.

What the server must send. Data-only and high priority. A notification block makes Android display the message itself while the app is in the background, and onMessageReceived is never called. Normal priority waits until Doze ends.

{ "message": {
    "token": "<device FCM token>",
    "android": { "priority": "high", "ttl": "300s" },
    "data": { "type": "fieldtrack_wake" } } }

When to send it. Send it on no upload or heartbeat for N minutes while a shift is open. Do not send it on no movement: a parked user legitimately stores nothing, and pushing every few minutes at a healthy device wastes the high-priority budget. Back off (for example 3 → 6 → 12 → 30 min) and stop once the device's last-seen time moves. FCM may downgrade high-priority messages that rarely lead to anything the user sees. A revival posts the tracking notification, which counts.

What it cannot do. It cannot reach an app that has been force-stopped. Android does not deliver FCM to a stopped package until the user opens it again. Some OEM killers (ColorOS o-kill, some MIUI paths) amount to a force-stop. On those devices only exempting the app from battery optimisation, and enabling the OEM's auto-launch or background-activity toggle, keeps tracking alive.

Cold start. firebase-messaging adds FirebaseInitProvider to every cold start of your process, and that runs inside the window before the tracking service must promote to the foreground (§1.7). It is normally tens of milliseconds, but measure it on your slowest device.


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

FieldDefault
basePathColorColor.argb(190, 66, 66, 66)
basePathWidth16f
speedOverlayWidth16f
speedOverlayAlpha160
cameraPaddingPx80
cameraPaddingFallbackPx50
arrowSizePx48
arrowColorColor.WHITE
showStopMarkerstrue
showArrowstrue

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

ValueBehaviour
NONECamera untouched — you own it
FOLLOWCentre on the puck, north-up, keeping the user's zoom
FOLLOW_BEARINGNavigation look: puck-centred, heading-up, tilted

LiveTrackRenderer.Options

FieldDefault
tailColor / headColorColor.argb(230, 26, 115, 232)
tailWidth / headWidth14f
puckSizePx56
puckColorColor.rgb(26, 115, 232)
showAccuracyHalotrue
haloFillColorColor.argb(26, 26, 115, 232)
haloStrokeColorColor.argb(90, 26, 115, 232)
animationDurationMs1_000 — ease duration ≈ the fix interval
lookaheadMs1_000 — dead-reckoning horizon; match animationDurationMs
cameraFollowCameraFollowMode.NONE
followZoom17f — applied on the first followed frame only
followTilt50f

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.

The module carries two independent channels: positions, below, and diagnostics (§15) — separately configured, separately credentialed, and unable to affect one another. This section is the first.

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

FieldTypeDefaultWhat it does
urlString—Full endpoint. Must be https:// (or http:// for loopback / with allowCleartext)
methodString"POST"HTTP method. POST, PUT or PATCH only — see below
headersMap<String, String>emptySent on every request. Never exposed back — they carry your credential
autoSyncBooleantrueUpload as points arrive, plus the connectivity and supervision triggers. With it off you call syncNow() / requestSync() — except that closing a session with rows queued always enqueues a drain, because stop() tears down every path that could otherwise notice
batchSizeInt100Rows per request, 1..1000. Larger = fewer requests but a bigger retry unit
requiresUnmeteredNetworkBooleanfalseOnly upload on Wi-Fi
gzipRequestBodyBooleanfalseCompress the JSON body. Off by default — there is no negotiation for request-body encoding, so a server that does not expect gzip answers 400
allowCleartextBooleanfalsePermit an http:// URL. Local development only. Loopback hosts (localhost, 127.0.0.1, ::1, 10.0.2.2) are already exempt
timeoutsSyncTimeouts5 s / 30 s / 20 sApplied by the built-in transport; ignored by a custom one
includePointSessionIdBooleanfalseStamp every uploaded point with the session that recorded it (session_id on each row of location). The envelope's session_id describes a whole batch, and a backlog drained after a process kill can hold rows from two drives — this is the only field that says which is which. Off by default, so the body stays byte-identical to every previous release. Set it on any host that records offline
extraParamsMap<String, Any>emptyMerged into the top level of every request body, alongside the location array — see §14.1.1

method accepts POST, PUT or PATCH, 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 own SyncTransport (§14.6) — the interface has no such restriction.

The SDK stores nothing about this config, so call configure() from Application.onCreate. Your credential never reaches disk — but neither does the rest of the config, and that has a consequence worth planning for: WorkManager persists the upload request, not your SyncConfig, and the process the OS builds to run that request has executed none of your code past Application.onCreate. A host that configures only after login, in an Activity, leaves every such process unconfigured — the worker finds nothing, reports done, and the rows wait for the app to be reopened.

Configure with whatever you have at launch and call configure() again when the token arrives. It is safe to call repeatedly: it replaces the config, re-registers the trigger, and clears a 403 halt.

Builder: .url(), .baseUrl(), .path(), .method(), .header(name, value), .headers(map), .autoSync(), .batchSize(), .requiresUnmeteredNetwork(), .gzipRequestBody(), .allowCleartext(), .timeouts(SyncTimeouts), .timeouts(connectMs, readMs, writeMs), .includePointSessionId(), .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

MemberSignatureNotes
getInstance@JvmStatic fun getInstance(context: Context): TrackerSyncIdempotent, thread-safe
configurefun configure(config: SyncConfig, transport: SyncTransport? = null)Throws IllegalArgumentException on an invalid config. Omit transport to use the built-in Retrofit-over-OkHttp default
endpointval endpoint: String?Where uploads go, or null if unconfigured — or if a 401 tore it down. Headers are deliberately not exposed
isConfiguredval isConfigured: BooleanDerived from endpoint. Do not cache it — a 401 clears configuration with no involvement from you
pendingCountsuspend fun pendingCount(): IntRows waiting to upload
requestSyncfun requestSync()Enqueues a network-constrained one-shot via WorkManager. Safe to call often. No-op once halted by a 403
syncNowsuspend fun syncNow(): SyncQueue.ResultDrains inline in the caller's scope. Prefer requestSync() for anything not user-initiated
eventsval 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.

TrackerSync also carries the log channel — configureLogs, log, logLifecycle, getLogs, syncLogsNow, requestLogSync, logEvents and their state. Those are listed in §15.4, because none of them does anything until you turn that channel on.

14.4 Terminal failure semantics

StatusBehaviour
2xxBatch accepted and marked synced
401 UnauthorizedTerminal. 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 ForbiddenTerminal, but non-destructive. Uploads halt, rows are kept, tracking continues. Recovery is calling configure() again with a working credential
Anything elseRows stay queued and retry with linear backoff (30 s base) via WorkManager, network-constrained
Retry-After headerHonoured — 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.

The durable half needs a config, and a cold process has none. WorkManager persists the request, not your SyncConfig — and the process it builds to run that request has executed none of your code past Application.onCreate. Nothing is read back from disk, so if configure() has not run in that process the worker finds nothing configured and reports done, and the rows stay queued until your app is next opened. Configure in Application.onCreate (§14.1), and call configure() again later if a login changes the credential.

When a session ends

Closing a session with rows still queued enqueues a drain, whatever autoSync says, as long as configure() has run.

It is not gated on the flag because autoSync is a statement about cadence — "not as points arrive" — and the end of a session is not an arrival. It is the last moment the SDK is watching at all: stop() cancels the backstop worker and stops the service the health loop runs in, so both supervision paths die with the session. A host that configured an endpoint and set autoSync = false therefore had nothing scheduled for a shift it recorded offline, and the rows waited until it next called syncNow() with a network by hand.

autoSync = false still means what it says: nothing uploads while the session runs. You get one drain when it ends, and WorkManager holds it until the network allows.

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.

PartValueLimits
MethodSyncConfig.methodPOST, PUT or PATCH only. Rejected at configure() otherwise
URLSyncConfig.urlMust be https://. http:// only for loopback (localhost, 127.0.0.1, ::1, 10.0.2.2) or with allowCleartext = true
Content-Typeapplication/json; charset=utf-8Not configurable
Content-Encodinggzip, only when gzipRequestBody = true and the body is at least 1,024 charactersBelow 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 headersSyncConfig.headersSent 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.

KeyTypeNotes and limits
(your keys)any JSONWhatever 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
locationarrayThe 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": []
}
FieldTypeAlways sent?Meaning and limits
uuidstringyesStable identity for this point. Dedupe on this — see §14.5.5
timenumberyesCapture time, epoch milliseconds, wall clock. Subject to device clock changes; integrity_flags reports when the clock looked untrustworthy
local_datestringyesyyyy-MM-dd in the point's own time_zone, for day bucketing without server-side zone maths
latitude / longitudenumberyesWGS-84 degrees. The fix's own coordinates, not a filtered estimate
accuracynumberyesHorizontal error radius in metres, as the platform reported it. No upper bound — a bad indoor fix can be thousands
movementSpeednumberyesMetres per second. 0.0 when the provider reported no speed — check hasSpeed before trusting it
providerobjectnoLocation subsystem at capture time. See §14.5.4. Absent on points captured before the SDK recorded it
hasSpeed / hasBearingbooleanyesWhether 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_zonestringyesIANA id, per point — a session can cross zones on a flight, so do not assume one zone per batch
activity_statusstringyes"<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_typestringnoOne of IN_VEHICLE, ON_BICYCLE, ON_FOOT, WALKING, RUNNING, STILL, TILTING, UNKNOWN. Enrichment only — see the caveat below
detected_activity_start_timenumberyesEpoch ms when that activity began; 0 when unknown
battery_percentagestringno0–100 as a string, e.g. "62". Absent when the platform will not say
is_chargingbooleannoPlugged in or full. Absent — not false — when the platform will not say
is_mockbooleanyesThe fix was flagged as mock by the OS. Android-only concept. Whether mock points are sent at all depends on policy — see §20.2
integrity_flagsnumberyesDevice-integrity bitmask at capture. 0 = nothing observed. Bit values are frozen — see §20.4
integrity_signalsarray of stringyesThe same information by name, for rules that prefer strings to bits. [] when nothing was observed

detected_activity_type is not a capture gate and should not be one server-side either. Entire multi-minute drives are reported STILL by 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

FieldTypeMeaning and limits
networkbooleanThe network (Wi-Fi/cell) provider is enabled
gpsbooleanThe GPS provider is enabled
enabledbooleanThe 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
statusnumberPermission 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
accuracyAuthorizationnumber0 full (fine location), 1 reduced (coarse only). Reduced means a 1–3 km error circle
airplanebooleanAirplane 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: provider was the provider name as a string ("fused"). The name is still on the wire — read it from activity_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.

StatusSDK behaviour
2xxAccepted. Rows marked synced, next batch drains immediately
401Terminal. Tracking stops, the queue is cleared, config is forgotten
403Terminal for retrying. Uploads halt, rows are kept, tracking continues
Anything elseRetried 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
}

14.7 Watching the API calls in logcat

Every upload the SDK makes is written to logcat as one line, under its own tag:

$ adb logcat -s FieldTrackApi
D/FieldTrackApi: POST points https://api.acme.test/v1/location/batch -> 200 success in 412ms (18422B gzip)
W/FieldTrackApi: POST logs   https://api.acme.test/v1/logs/batch -> 503 refused in 9004ms (2210B gzip) retryAfter=30000ms
W/FieldTrackApi: POST points https://api.acme.test/v1/location/batch -> - no_response in 30001ms (18422B gzip) "timeout"

-s FieldTrackApi is the device's whole API conversation and nothing else — both queues in one stream, points and logs naming which. A 2xx is d, everything else is w.

The word after the status is the outcome, and it is not derived from the status: a request that never reached a server has none, and reading that as a 500 is what makes "the API is down" indistinguishable from "this device has no signal". It is one of success, unauthorized, forbidden, refused, no_response, or a threw <Exception> for a transport that raised instead of answering.

Written at the SyncTransport seam, so it sees your client too if you supplied one — including a request one of your interceptors rewrote.

Debug builds only. The lines go through the SDK's internal sdkLog, which the release variant compiles out. A released app writes nothing here, which is the right default for output that any app on a rooted device can read.

Never printed: headers and bodies, in either direction, and the URL's query string and userinfo. A credential lives in all four. This is a diagnostic aid, not a proxy trace — add an OkHttp logging interceptor to a debug build if you need the wire itself.

Nothing here is stored or uploaded. The durable, uploaded channel is §15, and API calls are deliberately not part of it: a row per upload on disk is a different price, and for the log channel itself it would be an entry that the next log upload has to ship, which writes another one.


15. Log module — diagnostics to your backend

On by default whenever §14 is configured, and in the same artifact. configure() derives this channel from the points config it was just given, so a host that set up uploads gets diagnostics without a second setup call. Set SyncConfig.syncLogs = false and nothing below happens at all — no network, no database file, no battery. A host that never calls configure() never had a channel to begin with.

Points answer where the device was. They cannot answer why there is nothing there. A track with a twenty-minute hole in it is unreadable on its own — and every plausible cause is on the phone, not on your server:

On the deviceWhat your dashboard sees without logs
Battery optimiser killed the servicea track that stops
Location downgraded to "approximate"a track that gets vague
ACCESS_FINE_LOCATION denied on first runno device at all
A fix rejected by the accuracy gatea straight line where the road bends
The process crasheda track that stops

This channel makes those durable. tracker.events (§7.1) is a live notification that exists only while something is collecting it; the case you most need explained — a process an OEM killed mid-drive — is exactly the case where nobody was.

15.1 It is already on

One line — the one you wrote for points. configure() derives the log channel from the points endpoint:

val sync = TrackerSync.getInstance(context)

sync.configure(
    SyncConfig.builder()
        .baseUrl(BuildConfig.API_BASE_URL)
        .path("v1/location/batch")
        .header("Authorization", "Bearer $token")
        .extraParam("device_id", installId)
        .build()
)
// Diagnostics are now shipping to <same origin>/v1/logs/batch. No second call.

The derived config takes the URL from the origin of the SyncConfig plus v1/logs/batch, inherits device_id from SyncConfig.extraParams, and reuses the points headers.

Why on rather than off. These logs exist to explain the report that arrives as "tracking stopped on one phone yesterday" — and by the time it arrives, the window to have been collecting has closed. A channel switched on afterwards collects nothing about the incident that made someone want it.

It never fails your configure() call. If the endpoint cannot be derived — no device_id in extraParams, an unparseable URL — the SDK logs why under Tracker/TrackerSync and carries on with points working normally:

No diagnostic log channel: deviceId must not be blank …. Points are unaffected; call
configureLogs() to set one up.

Turn it off with one builder call, and nothing is derived at all:

SyncConfig.builder()
    .url("https://api.example.com/v1/location/batch")
    .syncLogs(false)
    .build()

device_id must be the same string on both channels. That join — a hole in a track, next to the reason for it — is the whole point. Two spellings of the same phone produce two unrelated datasets. Inheriting it is the default for that reason.

Point it somewhere else — or change the level, interval or credential — with an explicit configureLogs(). An explicit call always wins, and keeps winning: once made, later configure() calls leave your choice alone rather than re-deriving over it.

sync.configureLogs(
    LogSyncConfig.builder()
        .path("internal/diagnostics")               // or .url("https://logs.example.com/v1/logs/batch")
        .header("Authorization", "Bearer $logToken")  // its own credential — recommended
        .level(LogLevel.INFO)
        .uploadIntervalMinutes(15)
        .build()
)

Give this endpoint its own credential. A 401 on the points URL is destructive by design — it stops tracking and clears the queue (§14.4). A 401 here only stops log shipping. Keeping the scopes apart is what stops a diagnostics mistake reaching the point queue.

configureLogs() is idempotent and safe to call on every launch — call it right after configure(), in Application.onCreate. Unlike the derived default it does throw IllegalArgumentException if the resolved config does not validate, so wrap it the same way you wrap configure(): you asked for this endpoint by name, so a broken one is an error rather than a line in a log.

The two channels are independent in both directions. Either can be set without the other, neither can tear the other down, and no log failure can reach Tracker.stop(), the upload queue, or a stored position.

15.2 How the channel works

One pipeline, four stages: a line is recorded on the device, buffered in a bounded ring, drained on a schedule, and settled against what the server answered. Nothing in it can reach a position — the two channels share only the transport interface.

  Tracker.events ─────┐
  sync.log(…)         ├──►  LogRecorder  ──►  inbox · Channel(256, DROP_OLDEST)
  sync.logLifecycle(…)┘          │                    │
                                 │ level + type       │ never blocks the caller
                                 ▼ filter, truncate   ▼ one writer, ordered
                    log_entry · Room, fieldtrack-logs-<yourPackage>.db
                    a ring of bufferCapacity rows — oldest evicted, shipped or not
                                 │
              ┌──────────────────┴───────────────────┐
              │ heartbeat                            │ nudge
              │ LogSyncWorker, every                 │ an entry at nudgeLevel or above
              │ uploadIntervalMinutes (floor: 15)    │ asks for a drain now, throttled
              │ WorkManager, network-constrained     │ to one per nudgeCooldownMs
              └──────────────────┬───────────────────┘
                                 ▼
                        LogSyncQueue.drain()
                          1. recorded entries — batchSize at a time, ≤ 20 batches
                          2. decisions       — read from the SDK's own decision log,
                                               everything above a per-session watermark
                                 │
                                 ▼
                        SyncTransport.upload()  ──►  POST <logEndpoint>
                                 │                   one logcat line per exchange (§14.7)
                                 ▼
                        2xx → rows marked settled · watermark advanced
                        4xx → batch dropped, or the channel halts (§15.8)
                        5xx → nothing settled, retry with backoff

1 · Record. Tracker.events is collected for you, and log() / logLifecycle() add your own lines. Every draft goes through a bounded, single-consumer channel: the call never blocks the thread it came from, and one writer at the other end is what keeps seq meaningful. The channel drops its oldest on overflow, matching the ring below it — a diagnostic buffer that applied backpressure to the tracker it is diagnosing would be a worse bug than the ones it was added to find.

The write itself does four things: applies the level and types filters (a device set to INFO never stores a DEBUG line, so filtering costs no disk), truncates message to 4096, tag and code to 64, takes the next seq for that (session, type) pair, and derives the row's id — SHA-1("<sessionId>:<seq>:<type>:<elapsedRealtimeNanos>"), the dedupe key the whole channel is built on (§15.11).

2 · Buffer. Rows land in a second Room database, separate from the one holding positions. Each row is queued or settled, nothing else. The ring is trimmed to bufferCapacity newest rows — shipped and unshipped alike, because a buffer that refused to evict unsent rows is an unbounded buffer on exactly the device that cannot reach a server. Settled rows are pruned by age at retentionHours on the way out of a successful drain — an entry is only safe to forget once it has actually left the device — and queued rows are never pruned by age at all.

3 · Trigger. Two paths, and they cannot cancel each other — they run under separate unique work names:

PathEnqueued asWhen
Heartbeatperiodic, UPDATE policyevery uploadIntervalMinutes, floor 15 — WorkManager's own
Nudgeone-shot, KEEPan entry at nudgeLevel (default WARN) landed, at most one per nudgeCooldownMs; a burst inside the window is deferred to its end, not dropped
Retry-Afterone-shot, REPLACEthe server named a delay — its schedule wins over a pending one-shot
requestLogSync()one-shot, KEEPyou asked

Positions are shipped as soon as there is a network; diagnostics are read after the fact, by a person with a ticket open. That difference is the whole reason this channel is a heartbeat with a nudge rather than an event-driven queue: the radio wakes on a schedule you set, not on every line the SDK writes.

4 · Drain. LogSyncQueue.drain() is single-flight — a second one returns Retry("already draining") rather than queueing behind the first. It makes one pass:

  • Recorded entries first, batchSize rows at a time, up to 20 batches in a drain. Rows are re-filtered here as well as at record time, because a later configureLogs() can have narrowed level or types; an entry that no longer passes is settled rather than skipped, or it would sit at the head of every future batch forever. A 2xx settles the contiguous prefix up to that batch's last row id.
  • Decisions second, and only when LogType.DECISION is enabled. They are not mirrored into this buffer — at 1 Hz that is ~29 000 rows a shift written twice. They are read from the SDK's existing decision log at send time, for the open session (or the most recent one), everything newer than a stored watermark, which advances per batch so a failure halfway through a backlog does not re-ship what already landed. The scan is bounded at 20 pages of 500: a device offline for a week ships what it can and moves on.

Entries before decisions is deliberate on a device shipping both. An entry is what explains a gap; a decision is what fills one in. A drain cut short by a dead network should leave the more explanatory half already delivered.

5 · Settle. The response decides what happens to the batch — shipped, dropped, or retried — and, for four status codes, whether the channel keeps asking at all. That table is §15.8.

Delivery is at-least-once, by construction. A batch that reached the server and lost its response is re-sent whole, and every entry in it collides on its derived id rather than landing twice. Retrying is free, which is what lets the device settle rows on a 2xx it may never have seen.

15.3 LogSyncConfig

Every field has a default derived from the points endpoint, so the common call passes nothing.

FieldTypeDefaultWhat it does
urlStringderivedFull endpoint. Blank means "resolve it" — an absolute url here, else a bare path against TrackerConfig.baseUrl, else the points URL's origin plus v1/logs/batch
deviceIdStringinheritedBlank inherits SyncConfig.extraParams["device_id"]. Set it only if you genuinely need a different id — you almost certainly do not
methodString"POST"POST, PUT or PATCH, same restriction as §14.1
headersMap<String, String>inheritedEmpty inherits the points headers. Never exposed back
autoSyncBooleantrueRun the periodic drain. With it off you call syncLogsNow() / requestLogSync() yourself
levelLogLevelINFOMinimum severity recorded. Filtering happens when the entry is written, not when it is sent, so a device set to INFO never stores a DEBUG line
typesSet<LogType>EVENT, LIFECYCLE, MESSAGEWhich kinds are recorded and shipped. DECISION is absent on purpose — read its row in §15.7 before adding it
bufferCapacityInt5000Rows kept on the device. Oldest evicted first, shipped or not — a bounded buffer that refused to drop unsent rows would be unbounded on exactly the device that cannot reach a server. The resulting gap in seq is reported, never hidden
retentionHoursInt72How long a shipped entry is kept before pruning. Queued entries are never pruned by age
batchSizeInt200Entries per request, 1..500. The server answers 413 above its own ceiling and the SDK drops that batch rather than retrying it
requiresUnmeteredNetworkBooleanfalseOnly upload on Wi-Fi
gzipRequestBodyBooleantrueOn by default here, unlike SyncConfig. Log bodies are repetitive prose and compress around 8:1
allowCleartextBooleanfalsePermit http://. Local development only; loopback is already exempt
timeoutsSyncTimeouts5 s / 30 s / 20 sApplied by the built-in transport
uploadIntervalMinutesLong15Periodic drain cadence. 15 is WorkManager's floor and the config rejects less
nudgeLevelLogLevel?WARNThe severity that earns a prompt drain instead of waiting for the heartbeat. null disables it — see below
nudgeCooldownMsLong30000Shortest gap between two prompt drains. A burst inside the window is deferred to its end, not dropped
extraParamsMap<String, Any>emptyMerged into the top level of the body, beside logs. Cannot use logs, device_id, session_id, app, device or uploaded_at — the envelope owns those

nudgeLevel is what makes an incident arrive in seconds rather than in fifteen minutes. The channel is otherwise a quarter-hourly heartbeat, which is right for the volume and wrong for the entries somebody is waiting on — a GPS toggle, a permission revocation, a capture suspension. Those are WARN or above and are released early, throttled to one drain per nudgeCooldownMs. Lowering it to INFO is a battery decision, not a diagnostics one: on a busy device that is a radio wake every cooldown window.

15.4 The log API on TrackerSync

MemberSignatureNotes
configureLogsfun configureLogs(config: LogSyncConfig = LogSyncConfig(), transport: SyncTransport? = null)Turns the channel on. Idempotent. Throws IllegalArgumentException on a config that cannot be resolved
disableLogSyncfun disableLogSync()Stops recording and shipping. The buffer is kept — entries written before you turned it off still describe the period they were written in, and still ship if the channel comes back
logEndpointval logEndpoint: String?Where diagnostics go, or null if unconfigured — or if the endpoint refused the channel. Headers are deliberately not exposed
isLogSyncConfiguredval isLogSyncConfigured: BooleanDerived from logEndpoint. Do not cache it — a refusal clears configuration with no involvement from you
logfun log(level: LogLevel, tag: String, message: String, code: String? = null, data: String? = null)Records one host line as a MESSAGE. Fire and forget; a no-op until configureLogs() and unless the level and type pass its filters
logLifecyclefun logLifecycle(phase: String, tag: String = "Host")Records a boundary of your own — a shift starting, a job accepted. phase is a LifecyclePhase constant or your own string
getLogssuspend fun getLogs(sessionId: String? = null, limit: Int = 200, offset: Int = 0): List<LogRecord>The device buffer, newest first. null means every session, including entries belonging to none
pendingLogCountsuspend fun pendingLogCount(): IntEntries waiting to ship
requestLogSyncfun requestLogSync()Enqueues a network-constrained one-shot drain. Safe to call often. No-op once the endpoint has refused the channel
syncLogsNowsuspend fun syncLogsNow(): LogSyncQueue.ResultDrains inline in the caller's scope. Prefer requestLogSync() for anything not user-initiated — diagnostics are read after the fact
logEventsval logEvents: SharedFlow<SyncEvent>One event per completed log exchange. Replay 1

logEvents is a second flow rather than a second case on SyncEvent, so a screen showing one "last sync" badge is never forced to conflate a diagnostics upload failing with a positions upload failing.

15.5 What the SDK records without being asked

Once configured, the SDK writes its own TrackerEvent stream into the buffer. You do not subscribe to anything:

Recorded asLevelFrom
ErrorERRORAny TrackerEvent.Error — permission revoked, location disabled, storage full
CaptureSuspendedWARNCapture stopped while the session stayed open
CaptureResumedINFOCapture re-armed
PermissionChangeWARNThe grant moved, in either direction
LocationServicesChangeWARN off, INFO onThe device stopped or started being able to locate at all
ProviderChangeWARN on a provider or master-switch toggle, INFO otherwiseA GPS toggle behind an unchanged master switch emits nothing else, so it is logged at WARN and earns a prompt drain. A power-save, airplane or permission field moving stays INFO — each already has its own entry
PowerSaveChangeINFOBattery saver on/off
IntegrityChangeWARN with a blocking signal, else INFOThe device-integrity flag set changed
MotionChange, ActivityChangeDEBUGMotion state and activity transitions
GeofenceEntered / GeofenceExitedINFOA fence was crossed
EnabledChangeINFO, LIFECYCLESession start and stop — the only place a session boundary reaches your server
SessionInterruptedWARN, LIFECYCLEready() found a session left open by a crash
DEVICE_MOTIONWARN on POOR, else INFO, LIFECYCLEThe motion hardware this session is being captured on — see below

Deliberately not recorded: Location (that is what the points endpoint is for), LocationRejected (already in the SDK's decision log and read from there at send time — see LogType.DECISION), and Heartbeat and BatteryChange (volume, with nothing a reader would act on).

The SDK's own log lines

The events above are the SDK's structured output. Its running commentary — the lines that go to adb logcat under Tracker/… — is recorded too, from the moment a channel exists, with no log() call from you:

API_CALL        POST /verify -> HTTP 200 in 617ms, 352 bytes
API_CALL        verdict ACTIVE valid=true ttl=21600s -> carry on
SyncScheduler   Sync trigger registered
LocationStream  Provider went quiet for 94s — restarting the request

They arrive as LogType.MESSAGE, the same type as your own lines, with the SDK's tag naming where each came from. One ordered stream rather than two you have to merge by hand.

LogSyncConfig.level decides how much of it you get, and the default is not "all of it". The SDK logs at two levels internally: DEBUG for its commentary, WARN for something that went wrong. At the default level = INFO that means warnings are recorded and the commentary is not. Set .level(LogLevel.DEBUG) to capture everything — with the volume warning in §15.12 firmly in mind, because the SDK writes several lines per fix and a shift is thousands of rows.

In a release build you get the warnings, and only the warnings. This is the one part of the channel that survives release at all, and the line is drawn deliberately:

Debug buildRelease build
SDK warningsrecordedrecorded, once a channel is configured
SDK commentary (DEBUG)recorded at level = DEBUGnot present in the artifact
Tracker/… logcatwrittencompiled out

Logcat goes because anything holding READ_LOGS can read it, and a shipped app should not narrate itself there. The commentary goes for a second reason: its strings would have to ship inside the AAR to be writable at runtime, and those strings describe how the SDK works to anyone who unzips it — the release build is verified against a sample of them. Warnings are the small, high-value slice worth paying that price for, and at the default level = INFO they are the only thing the buffer keeps anyway.

So a released app records nothing until a log channel is configured, and records its warnings once one is. That is the point: the incident worth explaining is on a phone in the field, running a release build. To see the full commentary, reproduce on a debug build.

FieldTrackApi is the one tag never recorded. That is the upload log — one line per request, on both channels. Recorded, a log upload would write an entry describing itself, which the next upload ships, which writes another. It stays in logcat, where it costs nothing.

A provider transition also carries a filterable code — GPS_OFF, GPS_ON, NETWORK_OFF, NETWORK_ON, LOCATION_OFF, LOCATION_ON — plus previous_gps, previous_network and previous_enabled in its data.

The session's motion hardware. One entry at the head of every session, from the SDK's own SensorProbe rather than from a TrackerEvent:

{ "phase": "device_motion", "motion_quality": "DEGRADED",
  "accelerometer": true, "gyroscope": false, "magnetometer": true,
  "significant_motion": false, "step_detector": true, "step_counter": true,
  "barometer": false, "rotation_vector": true, "activity_recognition": true }

It is the answer to "why does this track have holes in it". Motion gating decides the capture cadence, and motion_quality is what the SDK decided it could trust:

motion_qualityWhat the SDK doesLevel
FULLNothing — accelerometer, gyroscope and a trigger sensor are all presentINFO
DEGRADEDDoubles motion.stopTimeoutMin; a stop is detected later and less certainlyINFO
POORForces CONTINUOUS — motion gating is not trustworthy on this hardwareWARN

DEGRADED stays at INFO on purpose: it is the ordinary state of a great deal of cheap hardware, the SDK already compensates for it, and warning on it would warn on half a fleet and therefore on none of it.

activity_recognition is reported separately because SensorProbe folds that grant into step_detector and step_counter — a false on either is no sensor or no permission, and a different phone and a prompt are not the same remedy.

Written once per session: on the session-start signal, and again if you call configureLogs() part-way through a session that is already open. Never without an open session to file it against — an entry describing a session belongs to one.

15.6 Writing your own lines

sync.log(
    level = LogLevel.WARN,
    tag = "Dispatch",
    message = "Job 8842 accepted with no route",
    code = "NO_ROUTE",
    data = """{"job_id":"8842","stop_count":0}""",
)

sync.logLifecycle(LifecyclePhase.SESSION_START, tag = "Shift")

data must be a JSON object or array as text, or null. Anything else is dropped — the entry is still stored, but without the payload — because the server's column is structured and one malformed value would spoil a batch carrying thirty useful entries.

Redaction is yours. Whatever you pass to log() is stored on disk and uploaded. A log line is the easiest place in any system to leak a token.

Nothing written here reaches logcat. This is the durable channel, which is the whole point: a live log cannot cover the process that was killed.

15.7 LogRecord and its enums

What getLogs() returns, and the shape the endpoint receives:

data class LogRecord(
    val id: String,                    // SHA-1("<sessionId>:<seq>:<type>:<elapsedRealtimeNanos>"), 40 hex
    val sessionId: String?,            // null for an entry that belongs to no session
    val seq: Long,                     // monotonic per session AND per type, from 0
    val timeMs: Long,                  // device wall clock — display only
    val elapsedRealtimeNanos: Long,    // monotonic since boot — the ordering key
    val level: LogLevel,
    val type: LogType,
    val tag: String,                   // subsystem: "CaptureGate", "ProviderState", your own
    val code: String?,                 // ErrorCode name, event name, or a Reasons string
    val message: String,
    val data: String?,                 // JSON object or array as text
)

id is deterministic, so a batch that reached the server and lost its response is re-sent whole and collides instead of duplicating. Retrying is free by design.

sessionId is null for an entry emitted between sessions — a boot, a service start, a config change. That is a real answer, not a gap: bucketing a boot-time entry under whichever session happened to be current at upload time would make it lie.

seq gaps are data, not errors. The buffer is bounded; a device logging for six hours offline drops its oldest entries, and the gap is what says so. A silent gap would read as "nothing happened", which is the one thing it does not mean.

LogLevel
DEBUGMotion and activity transitions, and the decision log
INFODefault. Provider and battery context, session boundaries
WARNSomething a person has to act on: a permission moved, a provider toggled, capture suspended
ERRORAny TrackerEvent.Error

LogLevel.admits(minimum) is public if you want to pre-filter your own lines the same way the recorder does.

LogTypeVolumeNotes
EVENTlowThe TrackerEvent stream, flattened
LIFECYCLEvery lowSession and service boundaries, plus one DEVICE_MOTION row per session (§15.5). Advisory — this channel is lossy, so annotate a session with it, never treat it as the sole truth for the session's bounds
MESSAGEyourslog()
DECISION~29 000 per device per 8-hour shiftWhy each fix was accepted or rejected, with the arithmetic. Off by default. Turn it on for a named device with a ticket open, never for a fleet — and note it is only recorded at DEBUG. It is read from the SDK's existing decision log at send time rather than written twice, and the first configureLogs() that enables it skips everything already recorded, so an opt-in ships the next drive rather than the last three days

LifecyclePhase holds the phase strings a LIFECYCLE entry carries in its data: session_start, session_stop, session_interrupted, service_start, service_stop, process_start, boot_completed, config_changed, device_motion.

device_motion is the one the SDK writes for you rather than one you pass to logLifecycle() — the session's motion hardware, described in §15.5.

15.8 Results and failure semantics

sealed interface LogSyncQueue.Result {
    data class Shipped(val count: Int) : Result
    data object Empty : Result
    data class Retry(val reason: String, val retryAfterMs: Long? = null) : Result
    data class Rejected(val statusCode: Int) : Result
}
StatusBehaviour
2xxStored. duplicates in the response are entries the server already held — not an error
401 / 403Terminal for this channel, non-destructive. Shipping and recording both halt, the buffer is kept, tracking and the point queue are untouched
404 / 405 / 501There is no endpoint here. Same halt: every later batch would collect the same answer, so the SDK stops asking rather than discarding your diagnostics a batch at a time. This is the case where your backend has not implemented the endpoint at all — it costs one request per process and nothing else
413Over the server's 500-entry ceiling. The batch is dropped, not retried — it will never be accepted
Other 4xxPermanently unacceptable. Batch dropped, drain moves on
503 / timeout / no responseTransient. Entries stay buffered and retry with backoff. Retry-After is honoured

This is the inverse of the points queue, on purpose. For a position, dropping is data loss and a retry loop is the lesser evil; for a log, a poison batch that blocks the buffer forever costs battery and buys nothing.

Recovery from any halt is the next configureLogs() — which, if you call it in Application.onCreate as recommended, means the next process start picks up an endpoint you deployed in the meantime, with the buffer intact.

Retry("already draining"), Retry("log sync not configured") and Retry("no transport") describe the SDK's own situation rather than a failed exchange. None is an upload error.

15.9 The wire format

POST <logEndpoint>
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Authorization: <your header>
{
  "device_id":   "8f14e45f-ceea-467a-9c1a-2b0a1e1f9c31",  // SAME id as the points envelope
  "uploaded_at": 1719400123456,                           // device wall clock at send

  // Constant for the life of a process, so it rides the batch rather than every entry.
  "app":    { "package": "com.acme.field", "version": "3.4.1", "build": 3401, "sdk": "1.0.8" },
  "device": { "manufacturer": "samsung", "model": "SM-A546E", "os": 34 },

  "logs": [
    {
      "id":            "3f1a…",                     // 40 hex, the dedupe key
      "session_id":    "20260907-143512-1f0c8a2e",  // per row, authoritative; null is legal
      "seq":           1482,
      "elapsed_nanos": "918273645000000",           // a STRING — see below
      "time":          1719400000000,
      "level":         "warn",                      // debug | info | warn | error
      "type":          "event",                     // event | decision | message | lifecycle
      "tag":           "ProviderState",
      "code":          "GPS_OFF",
      "message":       "GPS off",
      "data":          { "gps": false, "network": true, "previous_gps": true }
    }
  ]
}

elapsed_nanos travels as a string. The count passes 2^53 after 104 days of uptime and a JSON number rounds silently past that. Order on it, never on time — the wall clock can jump backwards mid-session; elapsedRealtimeNanos cannot.

Expected response:

{ "accepted": 487, "duplicates": 13, "rejected": 0, "batch_id": "lb_01J8…" }

Only two things get an entry rejected server-side, and both are structural: a missing id (the dedupe key) and a missing or non-numeric elapsed_nanos (the ordering key). Everything else should be coerced rather than refused — a log line that cannot be stored perfectly is still worth storing imperfectly.

data is type-specific and should be stored verbatim and unvalidated. The useful field is the one nobody modelled in advance.

typeShape of data
eventThe flattened TrackerEvent — e.g. {"gps":false,"network":true,"enabled":true,"permission":"FULL","previous_gps":true}
decision{"verdict":"REJECT","reason":"NLP Fallback","latitude":23.02,"longitude":72.57,"accuracy":48.0,"bearing_deg":118.4,"has_speed":true,"has_bearing":true,"filter_lat":23.0198,"filter_lng":72.5731,"sigma":6.4,"threshold":4.0,"distance_moved_m":287.4,"effective_speed_mps":23.9,"motion_state":"MOVING","point_uuid":"…"} — filter_lat/filter_lng are where the filter thought the device was, which is what makes a rejection readable; point_uuid is present only when the verdict is ACCEPT
lifecycle{"phase":"session_start"}
messageWhatever you passed. May be {}

One complete exchange — headers, a four-entry body, the response, and every other answer the endpoint can give — is §15.10.

15.10 A sample request and response

One exchange, end to end. This is what a backend implementing the endpoint has to accept, and what the device does with each answer.

The request

POST /v1/logs/batch HTTP/1.1
Host: api.acme.test
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Authorization: Bearer f3c1…            <- the log credential, not the points one
Accept-Encoding: gzip

The body below is what the server sees after decoding Content-Encoding: gzip. Four entries, oldest first — the session's motion line, a flattened event, a host line, and one decision:

{
  "device_id": "8f14e45f-ceea-467a-9c1a-2b0a1e1f9c31",
  "uploaded_at": 1719400123456,
  "app": {
    "package": "com.acme.field",
    "version": "3.4.1",
    "build": 3401,
    "sdk": "1.0.8"
  },
  "device": {
    "manufacturer": "samsung",
    "model": "SM-A546E",
    "os": 34
  },
  "logs": [
    {
      "id": "aff5f3e0a28c9ec0daf73553fa48fefbe73588b4",
      "session_id": "20260907-143512-1f0c8a2e",
      "seq": 0,
      "time": 1719399512004,
      "elapsed_nanos": "918273645000000",
      "level": "info",
      "type": "lifecycle",
      "tag": "Motion",
      "code": "DEVICE_MOTION",
      "message": "Motion DEGRADED - accelerometer, magnetometer, step detector",
      "data": {
        "phase": "device_motion",
        "motion_quality": "DEGRADED",
        "accelerometer": true,
        "gyroscope": false,
        "magnetometer": true,
        "significant_motion": false,
        "step_detector": true,
        "step_counter": true,
        "barometer": false,
        "rotation_vector": true,
        "activity_recognition": true
      }
    },
    {
      "id": "8cdaa79826af5d2747293c152ecdecb03b3248d3",
      "session_id": "20260907-143512-1f0c8a2e",
      "seq": 1482,
      "time": 1719400000000,
      "elapsed_nanos": "918891245000000",
      "level": "warn",
      "type": "event",
      "tag": "ProviderState",
      "code": "GPS_OFF",
      "message": "GPS off",
      "data": {
        "gps": false,
        "network": true,
        "enabled": true,
        "permission": "FULL",
        "previous_gps": true,
        "previous_network": true,
        "previous_enabled": true
      }
    },
    {
      "id": "eb8761f2fbb52e75294865a668415ac13d981873",
      "session_id": "20260907-143512-1f0c8a2e",
      "seq": 12,
      "time": 1719400004120,
      "elapsed_nanos": "918895365000000",
      "level": "warn",
      "type": "message",
      "tag": "Dispatch",
      "code": "NO_ROUTE",
      "message": "Job 8842 accepted with no route",
      "data": { "job_id": "8842", "stop_count": 0 }
    },
    {
      "id": "c97bceddc5852d219924b0318dfac530a2424b52",
      "session_id": "20260907-143512-1f0c8a2e",
      "seq": 29431,
      "time": 1719400005000,
      "elapsed_nanos": "918896245000000",
      "level": "debug",
      "type": "decision",
      "tag": "AcceptancePipeline",
      "code": "NLP Fallback",
      "message": "REJECT — NLP Fallback",
      "data": {
        "verdict": "REJECT",
        "reason": "NLP Fallback",
        "latitude": 23.0225,
        "longitude": 72.5714,
        "accuracy": 48.0,
        "bearing_deg": 118.4,
        "has_speed": true,
        "has_bearing": true,
        "filter_lat": 23.0198,
        "filter_lng": 72.5731,
        "sigma": 6.4,
        "threshold": 4.0,
        "distance_moved_m": 287.4,
        "effective_speed_mps": 23.9,
        "motion_state": "MOVING"
      }
    }
  ]
}

Three things to notice before writing the handler:

  • elapsed_nanos is a string, and it is the ordering key. time is the device wall clock and is display only — it can jump backwards mid-session.
  • seq is per session and per type. The 0, 1482, 12 and 29431 above are four independent counters, not a broken sequence. A gap inside one counter is the bounded buffer evicting its oldest rows: data, not an error.
  • data is not the same shape twice. Store it verbatim and unvalidated — the useful field is always the one nobody modelled in advance. An ACCEPT decision carries one more key, point_uuid, naming the point that reached your points endpoint.

The response

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "accepted": 3, "duplicates": 1, "rejected": 0, "batch_id": "lb_01J8XM4Z7QK2W9R0" }

accepted + duplicates + rejected must equal the number of entries the device sent — 4 here. The SDK does not read the counts (a 2xx settles the batch whatever they say), but a person reconciling a device against a dashboard does, and a number they cannot account for is a number that starts a bug report. If your server synthesises rows of its own — the reference backend writes one DEVICE_INFO line per session out of the envelope's app and device blocks — leave them out of all three counts.

duplicates is not an error. It is the expected cost of at-least-once delivery: a batch whose response was lost is re-sent whole, and every entry in it collides on its derived id.

Every other answer, and what the device does with it

ResponseThe SDK's behaviourLogSyncQueue.Result
200 / 201 / 204Rows settled, drain continues with the next batchShipped(n)
401, 403Channel halts — shipping and recording both stop. The buffer is kept, and tracking, the point queue and stored positions are untouched. Recovery is the next configureLogs()Rejected(401 or 403)
404, 405, 501Channel halts the same way — there is no endpoint at this URL, and every later batch would collect the same answer. One request per process, then silenceRejected(code)
413Batch dropped, not retried: it is over the server's entry ceiling and will never be accepteddrain continues
Other 4xxBatch dropped, drain continues with the next batchdrain continues
408, 429Retryable in 4xx clothing. Nothing settledRetry(msg, retryAfterMs)
5xx, timeout, no responseNothing settled, entries stay queued, backoff. A Retry-After reschedules the one-shotRetry(msg, retryAfterMs)
// 503, with Retry-After: 30
{ "error": "database unavailable" }

A 503 costs the device one wasted radio wake. A 404 costs it one per process. Both are safe answers while you are still building the endpoint — §15.8 is why those two are treated so differently.

Reproducing it with curl

The id is derived, so it can be computed outside the SDK. This posts one entry, and is the fastest way to prove a new endpoint accepts the shape:

SESSION=20260907-143512-1f0c8a2e
NANOS=918891245000000
ID=$(printf '%s' "$SESSION:1482:event:$NANOS" | sha1sum | cut -d' ' -f1)

curl -sS -X POST https://api.acme.test/v1/logs/batch \
  -H 'content-type: application/json; charset=utf-8' \
  -H 'authorization: Bearer <log token>' \
  --data-binary @- <<JSON
{
  "device_id": "8f14e45f-ceea-467a-9c1a-2b0a1e1f9c31",
  "uploaded_at": $(date +%s)000,
  "app":    { "package": "com.acme.field", "version": "3.4.1", "build": 3401, "sdk": "1.0.8" },
  "device": { "manufacturer": "samsung", "model": "SM-A546E", "os": 34 },
  "logs": [{
    "id": "$ID",
    "session_id": "$SESSION",
    "seq": 1482,
    "time": $(date +%s)000,
    "elapsed_nanos": "$NANOS",
    "level": "warn",
    "type": "event",
    "tag": "ProviderState",
    "code": "GPS_OFF",
    "message": "GPS off",
    "data": { "gps": false, "network": true, "previous_gps": true }
  }]
}
JSON

# {"accepted":1,"duplicates":0,"rejected":0,"batch_id":"lb_…"}
# run it a second time:
# {"accepted":0,"duplicates":1,"rejected":0,"batch_id":"lb_…"}

The second run is the contract working: same inputs, same id, no second row.

15.11 Schema architecture

Two stores, one derived key holding them together.

  DEVICE                                     BACKEND
  fieldtrack-logs-<yourPackage>.db           your database
  ────────────────────────────────           ──────────────────────────────────────

   log_entry                                  log_batches       one row per exchange
     id         INTEGER PK  local only          batch_id   PK
     uid        TEXT UNIQUE ────┐               device_id
     sessionId  TEXT NULL       │               status_code, entry_count,
     seq        INTEGER         │               accepted / duplicates / rejected
     …                          │               app_* / device_*   (per batch)
     syncState  0 queued        │                    │
                1 settled       │                    │ batch_id
                                │                    ▼
   log_counter                  │             session_logs      one row per entry
     seq:<session>:<type>       │               id          TEXT PK  ◄── uid
     decision:<session>         │               device_id   ────────►  your devices
                                │               session_id  ────────►  your sessions
   positions live in a          │               seq, elapsed_nanos, time
   SEPARATE file:               │               level, type, tag, code, message
   fieldtrack-<pkg>.db          │               data jsonb ─ point_uuid ─►  your points
                                │               batch_id
                                │
                                └──── POST /v1/logs/batch ────►

The device's uid becomes the server's primary key. That is the whole dedupe design: a re-sent batch collides instead of duplicating, so the device is free to settle rows on a response it may never have received.

On the device

A second Room database, created only when configureLogs() is called, named for your package so two apps embedding the SDK cannot collide, and kept separate from the file holding positions — which is what makes a credential failure on the log endpoint structurally unable to reach a stored point.

It is private to the SDK and is not public API: read it through getLogs() and pendingLogCount(), never by opening the file. The effective schema, for understanding what those calls are reading:

CREATE TABLE log_entry (
    id                   INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,  -- local insert order
    uid                  TEXT    NOT NULL,   -- the wire id; the server's PK
    sessionId            TEXT,               -- NULL for an entry outside any session
    seq                  INTEGER NOT NULL,
    timeMs               INTEGER NOT NULL,
    elapsedRealtimeNanos INTEGER NOT NULL,
    level                TEXT    NOT NULL,
    type                 TEXT    NOT NULL,
    tag                  TEXT    NOT NULL,
    code                 TEXT,
    message              TEXT    NOT NULL,
    data                 TEXT,               -- JSON as text, or NULL
    syncState            INTEGER NOT NULL    -- 0 queued, 1 shipped or dropped
);
CREATE UNIQUE INDEX index_log_entry_uid       ON log_entry(uid);
CREATE        INDEX index_log_entry_syncState ON log_entry(syncState);
CREATE        INDEX index_log_entry_timeMs    ON log_entry(timeMs);

CREATE TABLE log_counter (
    key   TEXT PRIMARY KEY NOT NULL,
    value INTEGER NOT NULL
);

Four decisions in that schema are worth knowing about, because they are what the API above behaves like:

id is local, uid is the identityInsert order is what a batch is a prefix of; uid is what the server dedupes on. Unique on uid means a double-record is dropped here rather than sent twice
syncState is the only send filterEvery level and type decision is made before a row exists, so a queued batch is always a contiguous prefix and settling by cursor is exact
The ring evicts by id, whatever syncState saysShipped and unshipped alike. A bounded buffer that refused to drop unsent rows would be unbounded on exactly the device that cannot reach a server
Two counters outlive the processseq:<sessionId>:<type> is the next sequence number for that pair; decision:<sessionId> is how far the decision log has been shipped, as an elapsedRealtimeNanos. A seq restarting at 0 after a process death would replay ids the server already holds

The entry id is derived, never allocated:

uid = SHA1("<sessionId>:<seq>:<type>:<elapsedRealtimeNanos>")     40 lowercase hex

SHA1("20260907-143512-1f0c8a2e:1482:event:918891245000000")
  = 8cdaa79826af5d2747293c152ecdecb03b3248d3

sessionId is hashed as the literal null when there is none. elapsedRealtimeNanos is in the digest as well as seq because seq alone repeats if a session's entries are all evicted and the counter restarts — a legible gap in a log, and a silent overwrite of somebody else's row without that term.

On your backend

Two tables. This is the shape the endpoint implies rather than a schema the SDK enforces — column types are PostgreSQL and translate directly:

CREATE TABLE log_batches (                    -- created first: session_logs points at it
    batch_id            text        PRIMARY KEY,
    device_id           text        NOT NULL,
    received_at         timestamptz NOT NULL DEFAULT now(),
    status_code         int         NOT NULL,
    entry_count         int         NOT NULL,
    accepted            int         NOT NULL DEFAULT 0,
    duplicates          int         NOT NULL DEFAULT 0,
    rejected            int         NOT NULL DEFAULT 0,
    error               text        NULL,
    gzip                boolean     NOT NULL DEFAULT false,
    app_package         text,
    app_version         text,
    app_build           bigint,
    sdk_version         text,
    device_manufacturer text,
    device_model        text,
    device_os           int
);

CREATE INDEX ON log_batches (device_id, received_at DESC);

CREATE TABLE session_logs (
    id            text        PRIMARY KEY,          -- the entry's 40-hex id
    device_id     text        NOT NULL,
    session_id    text        NULL,                 -- nullable is required, not optional
    seq           bigint      NOT NULL DEFAULT 0,
    elapsed_nanos bigint      NOT NULL,             -- arrives as a string; store 64-bit
    time          timestamptz NOT NULL,
    received_at   timestamptz NOT NULL DEFAULT now(),
    level         text        NOT NULL DEFAULT 'info',
    type          text        NOT NULL DEFAULT 'message',
    tag           text        NOT NULL DEFAULT 'app',
    code          text        NULL,
    message       text        NOT NULL DEFAULT '',
    data          jsonb       NULL,
    batch_id      text        NOT NULL REFERENCES log_batches(batch_id)
);

CREATE INDEX ON session_logs (device_id, received_at DESC);
CREATE INDEX ON session_logs (session_id, elapsed_nanos);
CREATE INDEX ON session_logs (device_id, code);

session_logs — one row per entry:

ColumnTypeNotes
idtext PKThe 40-hex id from the entry. Being the primary key is what makes an at-least-once re-send collide instead of duplicating
device_idtextFrom the envelope, or from your credential. The join to your points data
session_idtext nullPer-row value wins, then the envelope's, then null. Nullable is required, not optional
seqbigintPer session and per type. Gaps are eviction, not loss of integrity
elapsed_nanosbigintArrives as a string; store as 64-bit. Order on this
timetimestamptzDevice wall clock. Display only
received_attimestamptzYour clock, at insert. What you page by
leveltextdebug / info / warn / error. Coerce an unknown value to info rather than rejecting
typetextevent / decision / message / lifecycle. Coerce an unknown value to message
tagtext≤ 64 chars
codetext null≤ 64 chars. The column a week of one device's logs gets grouped by — index it
messagetext≤ 4096 chars. Truncate, do not reject
datajsonb nullVerbatim, never validated
batch_idtextThe batch it arrived in

log_batches — one row per HTTP exchange, written whatever the outcome, including the 413s and the auth failures:

ColumnTypeNotes
batch_idtext PKReturned to the device as batch_id
device_idtext
received_attimestamptz
status_codeintWhat you answered
entry_countintWhat arrived
accepted / duplicates / rejectedintWhat you did with them
errortext nullWhy, when there was one
gzipbooleanWhether the body was compressed
app_package / app_version / app_build / sdk_versiontext / text / bigint / textFrom the envelope's app
device_manufacturer / device_model / device_ostext / text / intFrom the envelope's device

Keep it separate from your point-ingest audit trail so a log flood is visible without polluting it. The per-batch app and device metadata lives here rather than on every entry — it is the same 200 bytes on each one otherwise, and it is what a "works on my device" ticket is actually about.

No row in log_batches at all means the request never reached your handler: a 404, a network failure, or it never left the device. A row means status_code, rejected and error say exactly what happened.

Only two things should get an entry rejected, and both are structural: a missing id (the dedupe key) and a missing or non-numeric elapsed_nanos (the ordering key). Everything else is coerced — a log line that cannot be stored perfectly is still worth storing imperfectly.

The four joins this schema exists for

FromToAnswers
session_logs.device_idyour points/devices table"the track has a hole at 14:02 — what was the phone doing?" This is why the two channels must send the same device_id string
session_logs.session_idyour sessions tableThe panel: one session's entries in order, elapsed_nanos ascending. null belongs to the device, not to a session, and is correct
data ->> 'point_uuid'a stored pointAn ACCEPT decision names the point that reached you; a REJECT is a point that never did, at the place where the polyline draws a straight line instead
session_logs.batch_idlog_batches"was this entry the last thing that got through" — and which build the device was running when it did

Retention

decision is the same order of volume as your points table and its rows are wider, so it is worth pruning on its own schedule rather than with everything else:

DELETE FROM session_logs WHERE type = 'decision' AND received_at < now() - interval '7 days';
DELETE FROM session_logs WHERE level = 'debug'   AND received_at < now() - interval '14 days';
DELETE FROM session_logs WHERE received_at < now() - interval '30 days';
DELETE FROM log_batches  WHERE received_at < now() - interval '7 days';

Deleting a session should delete its logs: a log line naming a deleted session is that session's data. The device does its own pruning independently — settled rows past retentionHours, and the ring at bufferCapacity — so nothing here needs the device's cooperation.

15.12 What it costs, and what to leave off

Entries per requestbatchSize, default 200, hard ceiling 500
Requestsone per uploadIntervalMinutes (default 15), plus one per nudgeCooldownMs when a WARN lands
Bodygzipped by default, roughly 8:1
Device storagebufferCapacity rows, default 5000, oldest evicted
Device retentionshipped entries pruned after retentionHours (default 72); queued entries are never pruned by age

Defaults are sized for a fleet: INFO, no DECISION, a quarter-hourly drain. That is a few kilobytes per device per shift.

LogType.DECISION is the one to think about. It is the same order of volume as track_point and the rows are wider. Enable it for one device while a ticket is open, and turn it off again — a DEBUG flag set during a ticket and never cleared is how one device ends up shipping 29 000 rows a day for a year.

level = DEBUG is the second one. It turns on the SDK's own running commentary (§15.5) — several lines per fix, so at a 15-second cadence, thousands of rows a shift. Same rule: one device, while a ticket is open. At the default INFO the SDK contributes its warnings and nothing else, which is what the "few kilobytes per shift" figure above assumes.

If you never want the channel at all, build your SyncConfig with .syncLogs(false) — or call disableLogSync() after the fact. Doing nothing now means the channel is on.


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

ParameterDefaultWhat it does
baseUrl—Your OSRM server
profile"driving"OSRM profile
clientbuilt-in OkHttpSupply your own. Retrofit runs on top of whatever you pass, so proxies, pinning and interceptors are all still yours
chunkSizeprovider defaultCoordinates per /match request
searchRadiusMprovider defaultSearch radius per coordinate
headersemptyExtra request headers
minConfidenceprovider defaultMatchings below this are discarded and keep raw coordinates. 0 accepts everything
cacheEntriesChunkCache.DEFAULT_MAX_ENTRIESMatched 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.

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


17. Diagnostics

Three layers, from rawest to most interpreted.

17.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 §20.4
)

17.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:

FieldMeaning
verdict"ACCEPT", "SKIP" or "REJECT"
reasonThe Reasons vocabulary string
isAcceptedverdict == "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.

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

motionState is the motion layer's verdict at the time the fix was judged. In releases before this one it was always STOPPED — the field had no writer anywhere in the SDK, so every row ever written recorded the default, on a motorway and on a desk alike. It is now stamped for real. It remains a label and nothing more: no gate reads it, because capture is never gated on motion detection.

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

ConstantString
INITInit
RESUMEResume
BURSTBurst
NLP_FALLBACKNLP Fallback
IMPOSSIBLE_SPEEDImpossible Speed
POOR_ACCURACYPoor Accuracy
RECOVERY_CONFIRMEDRecovery Confirmed
RECOVERY_RESETRecovery Reset
RECOVERY_HELDRecovery Held
SIGMA_GATE_OUTLIERSigma Gate Outlier
SIGMA_FORCED_RESETSigma Forced Reset
SIGMA_JUNK_FAILSigma Junk Fail
VEHICULARVehicular
MOVING_WALKINGMoving/Walking
INDOOR_ARRIVALIndoor Arrival
BEARING_CHANGEBearing Change
CORNER_ANCHORCorner Anchor
ARRIVALArrival
STATIONARY_RECOVERYStationary Recovery
BLACKOUT_ARRIVALBlackout Arrival
WALK_ARRIVALWalk Arrival
HEARTBEAT15-Min Heartbeat
ORIGIN_SETOrigin Set
DEPARTURE_HELDDeparture Held
DRIFT_SUPPRESSEDDrift Suppressed
HEARTBEAT_SKIPPEDHeartBeat Skipped
STILLNESS_VETOStillness Veto
HEURISTIC_GATEHeuristic Gate
SESSION_CLOSEDSession Closed
MOCK_LOCATIONMock Location
INVALID_COORDINATESInvalid Coordinates
STALE_FIXStale Fix
REBOOT_BOUNDARYReboot Boundary
OUT_OF_ORDEROut Of Order

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


19. 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.
  • The log channel's types (LogSyncConfig and its Builder, LogRecord, LogLevel, LogType, LifecyclePhase, LogSyncQueue.Result, and the wire DTOs) keep their names too, so configureLogs(...) compiles against the published AAR unchanged.
  • 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.


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

20.1 What is checked

SignalHowDefault
ACCESSIBILITY_SERVICE_ACTIVEA non-system accessibility service is enabled — the usual driver for UI automationWARN
DEVELOPER_MODE_ENABLEDSettings.Global.DEVELOPMENT_SETTINGS_ENABLEDWARN
ADB_ENABLEDSettings.Global.ADB_ENABLEDWARN
HOOKING_FRAMEWORK_DETECTEDFrida/Xposed: mapped libraries, agent thread names, default ports 27042/27043, TracerPid. Weighted; raised at confidence ≥ 60BLOCK
DEBUGGER_ATTACHEDTracerPid non-zero or Debug.isDebuggerConnected()BLOCK
AUTO_TIME_DISABLEDAutomatic date/time and automatic time zone both offWARN
TIMEZONE_MISMATCHDevice time zone not used in the serving cellular network's countryWARN
CLOCK_SKEWEDSystem clock disagrees with GNSS UTC by more than maxClockSkewMsWARN
MOCK_LOCATION_APP_SELECTEDA visible installed package holds the mock-location app-opBLOCK
MOCK_LOCATION_FIXThe platform flagged a delivered fix as mockBLOCK

No new permission is required, and QUERY_ALL_PACKAGES is deliberately not requested — see §20.5.

20.2 Policy

Three levels per group of signals:

PolicyReported to the hostStamped on points and uploadedBlocks ready()/start()
ALLOWnonono
WARNyesyesno
BLOCKyesyesyes
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:

BuildMock fixes
DebuggableStored, and uploaded with is_mock: true
ReleaseDropped, 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.

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

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

20.5 Limits worth knowing

  • Package visibility. From Android 11 the SDK cannot enumerate every installed app, so MOCK_LOCATION_APP_SELECTED catches a fake-GPS app only where the platform makes it visible. QUERY_ALL_PACKAGES would fix that and is deliberately not requested — it is a Play-policy declaration for every host, for a signal MOCK_LOCATION_FIX already 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.

20.6 Build-time checks

The SDK ships lint rules inside its AARs, so they run in your build:

IssueSeverityFires on
FieldTrackSecurityDisabledfatalsecurityEnabled(false) or IntegrityPolicy.ALLOW outside src/debug/
FieldTrackMockLocationAllowedfatalMockPolicy.ALLOW
FieldTrackDebuggableReleasefatalandroid:debuggable="true" in the manifest
FieldTrackLicenseHardcodedwarningA 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.


21. Troubleshooting

SymptomLikely causeFix
ready() returns LICENSE_MISSINGRelease build with no tokenSupply the token via .license(...) in config (§2)
start() returns NOT_READYready() not called or it failedCheck the TrackerResult from ready()
start() returns PERMISSION_DENIEDNo location permissionWalk the ladder in §4
start() returns PLAY_SERVICES_UNAVAILABLENo Google Play ServicesSet providerType = GPS_ONLY
Empty track, no points at allNETWORK_ONLY with a tight accuracy ceilingvalidate() rejects this — use AccuracyProfile.RELAXED or CUSTOM ≥ 50 m
Config changes do nothingreset = false with a persisted configSet reset = true (the default) during development
Very few points while stationaryWorking as designed — the data-plane heartbeat warms the filter without storingSet persistHeartbeat = true if you want them stored
Zigzag / drift while stationaryAccuracy ceiling too looseAccuracyProfile.STRICT, or a CUSTOM ceiling
Points keep arriving from a phone lying on a deskIndoor Wi-Fi/cell-centroid hops read as travel. The pipeline's own fix for this needs no configuration and is in the SDK alreadyUpdate the SDK first. If points still arrive, turn on motion.suppressWhileStationary — the accelerometer veto (§12.3)
suppressWhileStationary set, still storing pointsThe fix measured as moving, so the veto was never consulted — Doppler or displacement outranks the sensors by designRead the decision log: a withheld fix says Stillness Veto, a stored one names the gate that kept it (§17.3)
suppressWhileStationary had no effect at allNo accelerometer — the flag is turned off at ready() with a DiagnosticCheck tracker.getSensors().accelerometer. Nothing else to do; the pipeline's own defences still apply
Every FixDecision.motionState reads STOPPEDFixed — the field had no writer, so every row recorded the defaultUpdate the SDK (§17.3)
Corners drawn as straight chordsTurn fidelity settings offKeep 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 servicenavigationMode requires service.foregroundService — validate() enforces it
Tracking ends when the user swipes the app awaystopOnTerminate = trueLeave it false (the default)
Uploads retry foreverhttp:// URL blocked by Android's default network security policyUse https://, or allowCleartext = true for a local dev server
Uploads stopped, rows still queuedA 403 halted the queueCall sync.configure(...) again with a working credential
Queue does not drain when the network returnsautoSync = false, so only the durable half runs — nothing asks for a drain until the next enqueued work is releasedSet autoSync = true, or call syncNow() from your own connectivity handling
Rows left queued after stop() under autoSync = falseFixed — the session-close drain used to fire only when autoSync was on, and stop() tears down every other path that could noticeUpdate the SDK. Closing a session now enqueues a drain whenever sync is configured (§14.4)
No background uploads after the OEM kills the appYour app configures sync after login, so the process WorkManager builds for the worker has no config, and nothing is read back from diskCall configure() in Application.onCreate with whatever you have, and again when the token arrives (§14.1)
Background uploads stopped after adding a custom SyncTransportA worker process runs Application.onCreate and nothing else, so your transport is only installed if you install it thereConfigure sync, and supply your transport, from Application.onCreate so every process has both (§14.6)
NetworkAvailable arrives but nothing uploadsThe drain ran and failed — the event says a drain was requested, not that it succeededRead the HttpResponse that follows for the reason; a null statusCode means the request never completed
Backlog uploads in a scrambled orderFixed — the queue is FIFO by insertion, including across a rebootUpdate the SDK; older builds ordered on a monotonic clock that restarts at boot
Nothing ever reaches the log endpointsyncLogs = false, or the channel could not be derived — most often no device_id in SyncConfig.extraParamsLook for No diagnostic log channel: … under Tracker/TrackerSync at configure() time; it names what is missing. Or call configureLogs() explicitly (§15.1)
Log entries arrive up to 15 minutes lateWorking as designed: the channel is a quarter-hourly heartbeat, and only nudgeLevel and above drain promptlyNothing, or lower nudgeLevel to INFO — a battery cost, not a free one (§15.3)
Log shipping stopped on its own, entries still bufferedThe endpoint refused the channel: 401/403 on the credential, or 404/405/501 meaning there is no endpoint thereFix the route or the token; the next configureLogs() retries with the buffer intact (§15.8)
Logs saved under a device your points are not underLogSyncConfig.deviceId differs from SyncConfig.extraParams["device_id"]Leave deviceId blank so it inherits. The join between the two channels is that string (§15.1)
seq has gapsThe device buffer is bounded and evicted its oldest entriesExpected, and reported rather than hidden. Raise bufferCapacity or shorten uploadIntervalMinutes (§15.7)
A data payload is missing from an entry that is otherwise thereIt was not a JSON object or array, so it was dropped rather than sentPass valid JSON text to log(); the entry itself is always kept (§15.6)
Log volume far higher than expectedLogType.DECISION is enabled — roughly 29 000 entries per device per shiftRemove it from types, or keep it on one named device while a ticket is open (§15.12)
Tracking stopped and the queue emptiedA 401 tore everything downRe-authenticate, then ready() / start() / configure() again
The upload-status line vanished mid-sessionA 401 or 403 cleared the sync config — the line is only posted while sync is configuredCheck SyncEvent.HttpResponse for which, then the two rows above (§14.4)
The upload-status line never appearedshowSyncStatusInNotification left off, or configure() never calledTurn the flag on and configure sync; it is a diagnostic and stays off by default (§5.5)
{pending} shown literally on the notificationA typo'd token in syncNotificationTextOnly {pending} and {age} are substituted; an unknown token is left as written on purpose
The unsynced count looks frozenIt refreshes on the watchdogIntervalMs tick, and a parked device queues nothingRead {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 warningsYour snap provider could not answerNever fatal — the raw track is drawn. Check the OSRM server
MOTION_DETECTION_DEGRADEDmotionQuality = POOR on this hardwareCapture is forced to CONTINUOUS; expect more battery use (§12.1)
MOTION_ONLY behaves like CONTINUOUS, battery highmotionQuality = 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 degradedIt is emitted inside ready(), and events has replay = 0Read TrackerState.motionQuality instead — it always has a current value. Collect events before calling ready() if you want the event itself
Stops reported minutes latemotionQuality = DEGRADED — the SDK doubled stopTimeoutMinWorking 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_BLOCKEDA BLOCK-policy signal firedRead tracker.integrity() for the signals (§20); relax that policy to WARN if the device is legitimate
Session ends by itself with DEVICE_INTEGRITY_BLOCKEDThe health-loop re-check fired mid-sessionSame as above; integrityRecheckIntervalMs(0) disables the periodic re-check
Integrity findings never appearThe host app is debuggable, so the layer is waivedExpected. Check IntegrityReport.waived; exercise the layer in a release build
assembleRelease fails on FieldTrackSecurityDisabledA release source set disables the integrity layerMove the override to src/debug/ (§20.6)
Live map jumps backwardsDrawing a stale frameDrop any LiveTrackUpdate whose sequence is not newer than the last drawn
Long straight lines on the map, with nothing recorded in betweenThe process was frozen, killed or in Doze. Look in the device log for ProcessExit (o-kill, PACKAGE_UPDATED) and gaps between DEVICE_LOCATION re-initsExempt the app from battery optimisation and enable the OEM auto-launch toggle; wire remote wake (§12.4); draw time gaps as breaks on your map, not as travel
unknown_geofence:fieldtrack-stationary, then minutes with no points while drivingFixed. The stationary wake fence could stay registered with Play Services after the SDK lost its record of it, and the exit was droppedUpdate the SDK. The exit now wakes tracking whether or not the record exists (§11)
Server sends idle pushes but last_seen never movesNothing on the device handles them, the message has a notification block, or it is not high priorityHandle it with Tracker.wake() and send data-only, high-priority messages (§12.4)
remote wake: NO_SESSION in the logThe push arrived, but no session was open on the deviceExpected after end-of-day or a sign-out. Stop pushing to that device
remote wake: REFUSED in the logThe platform refused the foreground-service start — usually a normal-priority message, or an OEM ruleConfirm "priority": "high". The SDK retries on its own restore path

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 key

Verification API

The SDK handles licensing for you. This is documented for tooling.

POST https://sdk.fieldtrack360.com/api/v1/verify