Field Track 360

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. Snap module — road matching
  16. Diagnostics
  17. Java interop
  18. ProGuard / R8
  19. Device integrity
  20. 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 §15).

fieldtrack-core is different. It links Retrofit, OkHttp, Gson and Tink as real dependencies, because the licence check has to work in a host that brought no HTTP client of its own. There is no opt-out, deliberately: a licensing layer an integrator could disable by omitting a dependency would not be a licensing layer.

1.4 What you do not have to add

  • No DI framework. No Hilt, no @HiltAndroidApp, no KSP, no Gradle plugin. The SDK's object graph is wired internally.

  • No manifest entries. The AAR declares every permission, the foreground service and all three broadcast receivers; they merge into your APK automatically. Merged in:

    ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION, ACCESS_BACKGROUND_LOCATION, FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION, POST_NOTIFICATIONS, ACTIVITY_RECOGNITION (+ the Google Play Services variant), RECEIVE_BOOT_COMPLETED, WAKE_LOCK, ACCESS_NETWORK_STATE.

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


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

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

  • NONEstart() 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 (§19). 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

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)
MOTION_ONLYLocation fully off while stationary. Lowest battery, coarsest stop timing

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

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

A tier dropped at STOP_PENDING is parked, not forgotten: pulling away restores it on the MOVING transition rather than waiting for another fix to measure speed. A committed stop (STATIONARY) clears the claim, so whatever moves next earns the tier from speed again.

The battery difference between the three modes is mostly not the interval — it is whether the location stream stays registered while the device is parked. ADAPTIVE keeps it registered and lets the filter thin, because the heartbeat is what recovers a device whose wake paths all failed; MOTION_ONLY genuinely unregisters it. A phone parked for eight hours costs near zero on the third and a full day of radio duty on the first two.

The mode you set is not always the mode that runs. On a device where motion detection cannot be trusted, ready() rewrites this to CONTINUOUS — which is the most expensive mode short of navigationMode, so a host that chose MOTION_ONLY for battery gets the opposite. Read TrackerState.effectiveTrackingMode for what is actually in force, and §12.1 for when and why.

LocationProviderType

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

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
disableStopDetectionBooleanfalseNever enter the stationary state
stopOnStationaryBooleanfalseEnd the session automatically when stationary
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(), .disableStopDetection(), .stopOnStationary(), .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
useAccelerometerVetoBooleantrueReject "movement" with no accelerometer support
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(), .useAccelerometerVeto(), .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
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
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

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

The upload-status notification

Off by default, and it should stay off in a shipping app. The ongoing notification is the one piece of SDK surface a real user reads, and unsynced 42 means nothing to them while meaning something alarming.

What it is for is the one test that cannot be run from inside the app: kill the host, take the device offline, wait, restore connectivity, and confirm the queue drains — without launching anything, because launching the app is itself a sync trigger and would invalidate the test. The notification is the only readout that survives that, and it needs no debugger, no adb and no server-side check.

It occupies the subtitle and the body, never the title. The notification has three text slots and the host keeps the one that matters:

┌──────────────────────────────────────┐
│ Tracking active   ·   upload         │   ← notificationTitle · syncNotificationSubText
│ unsynced 42 · last upload 21m ago    │   ← syncNotificationText
└──────────────────────────────────────┘

With the diagnostic off — or on with no syncNotificationSubText set — there is no subtitle at all and the body is notificationText. The title reads the same either way, because it names the app holding the foreground service and a debug readout must not take that line.

syncNotificationText is substituted at post time:

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
  • 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
  • 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

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: ProviderStateGPS toggle, permission change, granularity change, battery saver
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
CaptureResumedCapture 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
GeofenceAdded / GeofenceRemovedgeofence / geofenceIdRegistry changed
GeofenceEntered / GeofenceExitedgeofence: TrackerGeofenceA fence was crossed
IntegrityChangereport: IntegrityReportThe device-integrity flag set changed — transitions only, not every check (§19)
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,
)

locationServicesEnabled is not the union of gpsEnabled and networkEnabled: a device can report location enabled with GPS switched off, and the master switch is what a "turn location on" prompt should be driven by.

airplaneMode is a diagnostic, never a gate — GPS keeps working in airplane mode on most devices while network positioning does not, so it explains a track that degrades to GPS-only or stops indoors rather than justifying a refusal to start.

Both are emitted on change through TrackerEvent.ProviderChange, like the rest of this object; there is no polling.

7.4 TrackerResult and ErrorCode

sealed interface TrackerResult<out T> {
    data class Ok<T>(val value: T) : TrackerResult<T>
    data class Error(val code: ErrorCode, val message: String) : TrackerResult<Nothing>
}
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
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 §19.4
    val providerFlags: Int = 0,          // location-subsystem snapshot at capture — see below
    val acceptReason: String,            // the Reasons vocabulary
)

providerFlags records what the location subsystem looked like when this point was captured — which providers were on, the master switch, the permission tier, accuracy authorization and airplane mode. Decode it with ProviderSnapshot:

data class ProviderSnapshot(
    val recorded: Boolean = false,              // false = no snapshot on this point
    val gpsEnabled: Boolean = false,
    val networkEnabled: Boolean = false,
    val locationServicesEnabled: Boolean = false,
    val airplaneMode: Boolean = false,
    val authorizationStatus: Int = STATUS_DENIED,       // 2 denied, 3 always, 4 while-in-use
    val accuracyAuthorization: Int = ACCURACY_REDUCED,  // 0 full, 1 reduced
)

val snapshot = ProviderSnapshot.fromFlags(point.providerFlags)
if (snapshot.recorded && !snapshot.gpsEnabled) {
    // this point came from network positioning only
}

The two numeric fields carry wire codes, not an SDK enum, because they are a contract with your backend and mean the same thing whichever platform sent them. Named constants are on the companion: STATUS_NOT_DETERMINED (0), STATUS_RESTRICTED (1), STATUS_DENIED (2), STATUS_ALWAYS (3), STATUS_WHEN_IN_USE (4), ACCURACY_FULL (0), ACCURACY_REDUCED (1). Android cannot tell "never asked" from "asked and refused", so it never emits 0 for status.

recorded is false — and every other field meaningless — for points captured before the SDK began recording this. That is deliberately distinct from a snapshot where everything is off: "we did not look" and "location was disabled" are different answers about a point that plainly exists.

It is captured per point rather than read when you ask, because the live ProviderState tells you about now: a track recorded over an hour can span a permission downgrade, and the point that stopped being precise is the one that carries the reason.

8.2 TrackSession

data class TrackSession(
    val id: String,
    val startedAtMs: Long,
    val startedAtElapsedNanos: Long,
    val endedAtMs: Long? = null,
    val tag: String? = null,
    val configSnapshot: String? = null,   // the config in effect, so old tracks stay interpretable
) {
    val isOpen: Boolean get() = endedAtMs == null
}

8.3 PointQuery

data class PointQuery(
    val sessionId: String? = null,
    val fromMs: Long? = null,
    val toMs: Long? = null,
    val limit: Int = 500,
    val offset: Int = 0,
)

8.4 Enums

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

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

TrackStatsdistanceMeters, durationSec, movingSec, stoppedSec, maxSpeedMps, avgMovingSpeedMps, pointCount, stopCount, activityBreakdownSec.

TrackSegmentfrom/to (inclusive indices into points), type (TRAVEL / STOP), startMs, endMs, distanceMeters, durationSec, avgSpeedMps, maxSpeedMps, p75SpeedMps, activity, activityIcon, speedBand, encodedPolyline, stopIndex.

StopNodeindex, lat, lng, arrivalMs, departureMs, dwellSec, radiusM, pointCount, address, isOngoing (pulse this marker — the session is still open).

ArrowAnchorlat, lng, bearing, segment. Precomputed so the renderer and the export cannot disagree about arrow placement.

TrackJsonPointi (the index every other array references), t, lat, lng, acc, spd, brg, act, src, mock.

Boundsnorth, south, east, west.

9.3 Export

val polylineJson = tracker.exportPolylineJson(PointQuery(sessionId = id))
val geoJson      = tracker.exportGeoJson(PointQuery(sessionId = id))

exportGeoJson produces an RFC 7946 FeatureCollection — coordinates are [lng, lat].

Encode/decode helpers are public:

val encoded = PolylineCodec.encode(points, precision = 6)
val decoded = PolylineCodec.decode(encoded, precision = 6)

val json  = TrackJson.encode(track)
val back  = TrackJson.decode(json)

PolylineCodec.Encoder and PolylineCodec.Decoder are streaming variants — add() / snapshot() and drain().


10. Live tracking

liveTrack() emits one frame per processed fix while a session is active: an append-only smoothed tail, the re-smoothed last span, and the filter's own position estimate for an animated puck. It is conflated — collectors always see the latest frame and can never slow capture down.

Use liveTrack() for a map that follows the user; use buildTrack() for the consolidated, snapped, segmented historical product.

lifecycleScope.launch {
    tracker.liveTrack().collect { update ->
        renderer.render(update)
    }
}

10.1 LiveTrackUpdate

data class LiveTrackUpdate(
    val sessionId: String,
    val sequence: Long,               // monotonic per session run — DROP a frame not newer than the last drawn
    val precision: Int,
    val frozenTailPolyline: String,   // encoded; grows by appending. Never re-smooth it
    val liveHead: List<GeoPoint>,     // the unsettled last span, including both end vertices
    val puck: PuckState?,             // null until the filter seeds
)

data class PuckState(
    val latitude: Double,
    val longitude: Double,
    val speedMps: Float,
    val headingDeg: Double?,          // null when velocity is too small; hold your last rotation
    val accuracyM: Float,             // 1σ uncertainty — the honest halo radius
)

liveHead's first vertex is the tail's last, so the two polylines join seamlessly.

10.2 Route snapping for the puck

tracker.setActiveRoute(routePolylinePoints)   // List<GeoPoint>; pass emptyList() to clear
if (tracker.isOffRoute()) offerReroute()

This projects the live puck onto the route your app is already navigating — entirely offline, no provider, no key, no quota. Only the puck moves. Stored points and buildTrack() are untouched, because the route is your claim about where the user intends to go, not evidence of where they were measured.

isOffRoute() becomes true only after the position misses the route for enough consecutive fixes to be a wrong turn rather than a multipath spike. Always false with no route set.


11. Geofences

Up to 19 host fences. The SDK's internal stationary wake fence uses a reserved slot and does not count.

val fence = TrackerGeofence(
    id = "warehouse",
    latitude = 23.0225,
    longitude = 72.5714,
    radiusM = 200f,
    onEnterEvent = "warehouse_enter",
    onExitEvent = "warehouse_exit",
)

when (val result = tracker.addGeofence(fence)) {
    is TrackerResult.Ok    -> Unit
    is TrackerResult.Error -> when (result.code) {
        ErrorCode.GEOFENCE_LIMIT_REACHED        -> pruneOldFences()
        ErrorCode.GEOFENCE_REGISTRATION_FAILED  -> retryLater()
        ErrorCode.INVALID_CONFIG                -> fixCoordinates()
        else                                    -> Unit
    }
}

Validation: non-blank id, latitude in −90..90, longitude in −180..180, radiusM > 0.

Crossings arrive as TrackerEvent.GeofenceEntered / GeofenceExited and are also persisted:

val history: List<TrackerGeofenceEvent> = tracker.getGeofenceEvents(
    geofenceId = "warehouse",
    fromMs = startOfDay,
    limit = 100,
)
val deleted: Int = tracker.deleteGeofenceEvents(geofenceId = "warehouse")
data class TrackerGeofenceEvent(
    val geofence: TrackerGeofence,
    val transition: GeofenceTransition,   // ENTER | EXIT
    val timestampMs: Long,
    val eventName: String,
)

Constants: TrackerGeofence.MAX_GEOFENCES = 19, DEFAULT_ID = "trackit-stationary", DEFAULT_ENTER_EVENT, DEFAULT_EXIT_EVENT.


12. Battery and sensors

val now: BatteryInfo = tracker.batteryInfo()            // reads the platform right now
val live: StateFlow<BatteryInfo> = tracker.batteryState()

batteryInfo() needs no session, no permission and no ready() call. It is a binder call — put it in a refresh, not a per-frame render; collect batteryState() for a live display.

data class BatteryInfo(
    val percent: Int? = null,             // null means "we do not know" — never 0 %
    val isCharging: Boolean? = null,
    val powerSource: PowerSource = PowerSource.UNKNOWN,
) {
    val isLow: Boolean                    // percent != null && percent <= 15
}

This is the same reading stamped on every stored point, so your display and your uploaded rows cannot disagree. TrackerEvent.BatteryChange carries the same transitions.

val sensors: DeviceSensors = tracker.getSensors()
data class DeviceSensors(
    val accelerometer: Boolean,
    val gyroscope: Boolean,
    val magnetometer: Boolean,
    val significantMotion: Boolean,
    val stepDetector: Boolean,
    val stepCounter: Boolean,
    val barometer: Boolean,
    val rotationVector: Boolean,
    val motionQuality: MotionQuality,     // FULL | DEGRADED | POOR
)

12.1 Motion hardware, and what it can override

MotionQuality is not a diagnostic. The SDK acts on it at ready(), before any session opens, because running a motion-gated design on hardware that cannot detect motion produces gaps a user blames on the SDK rather than on the device (EC-137).

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.


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.

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
urlStringFull 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. With it off, you call syncNow() / requestSync()
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
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.

Builder: .url(), .baseUrl(), .path(), .method(), .header(name, value), .headers(map), .autoSync(), .batchSize(), .requiresUnmeteredNetwork(), .gzipRequestBody(), .allowCleartext(), .timeouts(SyncTimeouts), .timeouts(connectMs, readMs, writeMs), .extraParam(name, value), .extraParams(map), .build(), .buildUnchecked().

baseUrl and path are joined with exactly one / regardless of which side carries it.

14.1.1 extraParams — your own body fields

Most backends want the batch inside an envelope carrying identity, not on its own. Anything you put in extraParams is merged into the top level of the request body, before the location array:

SyncConfig.builder()
    .baseUrl(BuildConfig.API_BASE_URL)
    .path("v1/location/batch")
    .header("Authorization", "Bearer $token")
    .extraParam("user_id", userId)
    .extraParam("device_id", deviceId)
    .extraParam("company_id", 7)
    .build()

produces:

{
  "user_id": "u-42",
  "device_id": "d-88",
  "company_id": 7,
  "location": [ { "uuid": "…" } ]
}

Values may be a String, a Boolean, any boxed number, or a Map / List / array of those for nested structures. Numbers stay numbers and booleans stay booleans — nothing is stringified on the way out. null is not a value: omit the key instead.

configure() rejects an unusable value rather than failing on the first upload. An unserializable object is reported by key name at configuration time — the point where you are still holding the config you wrote — rather than mid-drain hours later, where a batch that cannot be sent has no good answer. The key location is reserved, since that is the batch itself.

With no extraParams set, the body is byte-identical to previous releases — this is additive, and an existing backend needs no change.

These are static config, like headers. A rotating token belongs in a re-configure() call, or in your own SyncTransport where you can compute it per request.

data class SyncTimeouts(
    val connectMs: Long = 5_000,
    val readMs: Long = 30_000,
    val writeMs: Long = 20_000,
)

14.2 TrackerSync API

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.

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.

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. ReservedextraParams 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 §19.2
integrity_flagsnumberyesDevice-integrity bitmask at capture. 0 = nothing observed. Bit values are frozen — see §19.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
}

15. Snap module — road matching

Optional. With no provider installed, buildTrack() never leaves the device and never emits a snap_unavailable warning.

tracker.setRoadSnapProvider(
    OsrmSnapProvider(baseUrl = "https://osrm.example.com")
)

There is no default baseUrl on purpose — the public OSRM demo server has no availability guarantee. Point this at your own deployment.

OsrmSnapProvider parameters

ParameterDefaultWhat it does
baseUrlYour 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.

15.1 Writing your own provider

interface RoadSnapProvider {
    suspend fun snap(path: List<GeoPoint>): List<GeoPoint>
    suspend fun snap(request: SnapRequest): List<GeoPoint> = snap(request.path)   // richer, optional

    object Disabled : RoadSnapProvider
}

data class SnapFix(val point: GeoPoint, val timeMs: Long = 0, val accuracyM: Float = 0f)
data class SnapRequest(val fixes: List<SnapFix>) {
    val path: List<GeoPoint>
    val hasTimestamps: Boolean
}

Implementations must degrade rather than fail: returning an empty list makes the builder fall back to raw geometry and emit snap_unavailable rather than losing the track. Any exception you do throw is caught and turned into ErrorCode.SNAP_UNAVAILABLE — it is never fatal.

The 80 m snapMaxOffRoadM guard means a parallel service road can never relocate the user.


16. Diagnostics

Three layers, from rawest to most interpreted.

16.1 Layer 1 — raw fixes

Requires persistence.persistRawFixes = true.

val raw: List<RawFix> = tracker.getRawFixes(sessionId)
data class RawFix(
    val timeMs: Long,
    val latitude: Double,
    val longitude: Double,
    val accuracy: Float,
    val bearingDeg: Float,      // 0f when the provider reported no bearing
    val provider: String,
    val integrityFlags: Int,    // device-integrity bitmask when received — see §19.4
)

16.2 Layer 2 — raw points

Requires persistence.persistRawPoints = true. Every judged fix in point form, accepted or not — the layer to reach for when the question is "why is there no point here" rather than "why is this point wrong". RawPoint has the same columns as TrackPoint plus:

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.

16.3 Layer 3 — the decision log

On by default (persistence.persistDecisions = true).

val decisions: List<FixDecision> = tracker.getDecisions(sessionId, limit = 200, offset = 0)
data class FixDecision(
    val fix: TrackFix,
    val verdict: Verdict,             // Accept | Skip | Reject, each carrying a reason
    val filterLat: Double,
    val filterLng: Double,
    val sigma: Float,                 // how far the fix was, in filter sigmas
    val threshold: Float,             // how wide the gate was
    val distanceMovedM: Double,
    val effectiveSpeedMps: Float,
    val motionState: MotionState,
) {
    val reason: String
    val isAccept: Boolean
}

The numeric fields exist so a Sigma Gate Outlier can be argued with.

16.4 Reasons — the reason vocabulary is API

These exact strings appear on TrackPoint.acceptReason, RawPoint.reason and FixDecision.reason. They are stable; changing one is a breaking change.

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
HEURISTIC_GATEHeuristic Gate
SESSION_CLOSEDSession Closed
MOCK_LOCATIONMock Location
INVALID_COORDINATESInvalid Coordinates
STALE_FIXStale Fix
REBOOT_BOUNDARYReboot Boundary
OUT_OF_ORDEROut Of Order

17. Java interop

Every entry point is Java-callable. getInstance, TrackerConfig.builder() and SyncConfig.builder() are @JvmStatic; PointQuery, TrackOptions and the paged query methods carry @JvmOverloads.

Tracker tracker = Tracker.getInstance(context);

TrackerConfig config = TrackerConfig.builder()
        .provider(LocationProviderType.GPS_ONLY)
        .accuracyProfile(AccuracyProfile.STRICT)
        .intervalMs(30_000L)
        .notification("Tracking", "Recording your route")
        .build();

suspend functions need a coroutine. From Java, call them from Kotlin glue, or wrap them in your own CoroutineScope helper. Flows are consumed the same way.

TrackerConfig.Builder.build() and SyncConfig.Builder.build() throw IllegalArgumentException on an invalid config — use buildUnchecked() plus validate() if you are assembling config from untrusted input.


18. ProGuard / R8

You do not need to add any rules. Each AAR ships consumer-rules.pro and the published artifacts are already R8-minified.

What this means in practice:

  • Public API types and the documented extension seams (TrackLogger, RoadSnapProvider, SyncTransport) keep their names.
  • Model classes (Track, TrackOptions, TrackSegment, TrackStats, TrackJsonPoint, StopNode, ArrowAnchor, LiveTrackUpdate, PuckState, SegmentType, Smoothing, …) keep public class and member names, so named accessors survive.
  • Enum constants are preserved — persisted rows and wire values use name/valueOf.
  • SDK logging is compiled out of release builds entirely.
  • No sources JAR is published; a Javadoc JAR with rendered public API HTML is.

If you hit a NoSuchMethodError or a serialization failure after enabling minification in your own app, that is a bug worth reporting — do not paper over it with -keep class com.field360.tracker.** { *; }, which would disable shrinking for the whole SDK inside your APK.


19. Device integrity

A second security layer beside the license gate. It answers one question — can this device fabricate the location data it is about to send? — and lets you decide what to do about the answer.

Release only. Every probe is skipped and every policy ignored when the host app is debuggable, exactly as the license check is waived there. Development builds, emulators and instrumentation runs are unaffected, with nothing to remember to switch off and nothing that could survive into production.

19.1 What is checked

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 §19.5.

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

19.3 Reading the result

when (val result = tracker.ready(config)) {
    is TrackerResult.Error ->
        if (result.code == ErrorCode.DEVICE_INTEGRITY_BLOCKED) {
            // result.message names the blocking signals
            val report = tracker.integrity()
            showBlockedScreen(report.blockingSignals)
        }
    is TrackerResult.Ok -> Unit
}

// Live, and re-checked inside the health loop while a session is open.
tracker.integrityState()
    .onEach { report -> banner.isVisible = report.findings.isNotEmpty() }
    .launchIn(scope)

// Force a fresh evaluation — reads /proc, the package list and a loopback socket.
val fresh = tracker.checkIntegrity()

TrackerEvent.IntegrityChange is emitted when the flag set changes, not on every evaluation. A BLOCK finding also arrives as TrackerEvent.Error with ErrorCode.DEVICE_INTEGRITY_BLOCKED, and mid-session it ends the session.

IntegrityReport.waived is true in a debuggable build: nothing was probed, and the empty findings list is not a claim that the device is clean.

19.4 On the wire and in storage

Every accepted point carries integrityFlags — the bitmask of every signal observed when it was captured, WARN and BLOCK alike. It is persisted on the point, readable through TrackPoint.integrityFlags, and uploaded by fieldtrack-sync:

{
  "uuid": "…",
  "is_mock": false,
  "integrity_flags": 130,
  "integrity_signals": ["DEVELOPER_MODE_ENABLED", "MOCK_LOCATION_APP_SELECTED"]
}

The bit assignments are frozen: ACCESSIBILITY_SERVICE_ACTIVE = 1, DEVELOPER_MODE_ENABLED = 2, ADB_ENABLED = 4, HOOKING_FRAMEWORK_DETECTED = 8, DEBUGGER_ATTACHED = 16, AUTO_TIME_DISABLED = 32, TIMEZONE_MISMATCH = 64, MOCK_LOCATION_APP_SELECTED = 128, MOCK_LOCATION_FIX = 256, CLOCK_SKEWED = 512.

Both fields default, so a backend that has never seen them keeps parsing. 0 means "nothing observed" — which is also what a debuggable build and a host with the layer disabled send, so tell "clean" from "not evaluated" by the client version, not by this column.

Evaluate server-side as well. These flags are advisory input to a server rule, never the whole defence: an attacker who has already hooked the process can patch the client that produces them. The value is that tampering has to defeat both sides.

19.5 Limits worth knowing

  • Package visibility. From Android 11 the SDK cannot enumerate every installed app, so MOCK_LOCATION_APP_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.

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


20. 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
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.foregroundServicevalidate() 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
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
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 (§19); 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/ (§19.6)
Live map jumps backwardsDrawing a stale frameDrop any LiveTrackUpdate whose sequence is not newer than the last drawn

Try it before you buy

A 30-day trial licence for one application, issued instantly. Development builds are licence-waived, so you can evaluate the whole SDK first.

Get a trial key

Verification API

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

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