Developer guide
Integrate the SDK
Native SDKs for Android and iOS, with bridges for React Native and Flutter. Pick your platform - the setup genuinely differs, so these are not one page.
FieldTrack Tracker React-Native — Integration Guide
FieldTrack360 Tracker for React Native — a background location-tracking plugin with two native map components, built for React Native (CLI). New Architecture only — TurboModule + Fabric.
One TypeScript API gives your app the complete capture pipeline, identical on iOS and Android: a background location session that survives app suspension and reboot, motion/activity-aware cadence, a stored point history with an odometer, geofences, road-snapped track plotting, native map rendering, and an upload (sync) engine.
Primary target: React Native (CLI). Expo is supported through the bundled config plugin (see Expo).
Table of contents
- Overview
- Requirements
- Installation
- Licensing
- Initialization and ready state
- Permissions
- Location
- Sessions
- MapView
- Geofences
- Sync
- Complete API reference
- Types
- End-to-end example
- Troubleshooting
- Known limitations
- License
Overview
| Package | @fieldtrack360/react-native-tracker |
| Platforms | iOS 17+, Android API 26+ |
| Architecture | New Architecture only (TurboModule + Fabric codegen) |
Requirements
| Minimum | Notes | |
|---|---|---|
| React Native | 0.81+ with New Architecture enabled | Developed and verified against RN 0.87 |
| Node | 22+ | Needed for the iOS framework fetch on install |
| iOS | 17.0 | The vendored XCFrameworks target arm64-apple-ios17.0 and are not weak-linked |
| Xcode / CocoaPods | Xcode 27.1+ (Swift 6.4), CocoaPods 1.15+ | The pinned iOS SDK is built with Swift 6.4; an older Xcode cannot read its module interfaces |
| Android | minSdk 26, compileSdk 36, target 36 | The config plugin raises minSdkVersion/compileSdkVersion for you; it never lowers them |
| Kotlin | 2.0+ (verified: 2.1.20) | React Native's own pin is enough — no override needed |
| AGP | 8.x+ (verified: 8.11) | |
| JDK | 17 (11+ works) | |
| Maps | A Google Maps API key — optional, and only on Android | Needed solely to render <TrackMapView> / <LiveTrackMapView>. Tracking, sessions, geofences and sync need no key |
None of these floors is adjustable from the bridge.
Installation
npm install @fieldtrack360/react-native-tracker
# or
yarn add @fieldtrack360/react-native-tracker
Then install the pods (iOS only):
cd ios && pod install
Nothing else to run by hand — a postinstall script fetches and checksum-verifies the iOS
XCFrameworks into the package's ios/Frameworks/ before pod install needs them. If that
directory ends up empty, see Troubleshooting → Installation.
The rest of this section is the per-platform host setup: Android, iOS, Expo.
Android setup
1 — Google Maps API key — only if you use the map components. <TrackMapView> and
<LiveTrackMapView> render through Google Maps on Android and need a key; every other part of the
SDK (capture, sessions, geofences, plotting exports, sync) works without one, so skip this step
entirely if your app does not mount either component. In
android/app/src/main/AndroidManifest.xml:
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" />
and fill the placeholder from a gitignored file in android/app/build.gradle:
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { localProperties.load(it) }
}
def mapsApiKey = localProperties.getProperty("MAPS_API_KEY", "")
android {
defaultConfig {
manifestPlaceholders["MAPS_API_KEY"] = mapsApiKey
}
}
Without the <meta-data> element the map surface comes up blank at runtime with only a
logcat line to explain it.
2 — (Optional) early init. Android has no launch-window trap, but the SDK recommends
Application.onCreate timing for filter-state restore:
// android/app/src/main/java/.../MainApplication.kt
import com.fieldtrack360.tracker.TrackerLaunch
override fun onCreate() {
super.onCreate()
TrackerLaunch.ready(this) // optional
loadReactNative(this)
}
TrackerLaunch.ready(context) is fire-and-forget with SDK defaults; a later Tracker.ready(config)
from JS re-applies real config and is safe. It becomes required, not optional, if you use
headless events.
3 — (Recommended) take WorkManager off the cold-start path. A manifest block, no Kotlin. In
android/app/src/main/AndroidManifest.xml, add the tools namespace to <manifest> and this inside
<application>:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>
WorkManager otherwise initialises itself from a ContentProvider on the main thread of every cold
start, before Application.onCreate. The tracking service is started with
startForegroundService(), and the platform's ~10 second start-foreground deadline opens at that
call — while the process is very often starting cold to answer it (sticky restart, boot, OEM
revival). Overrun it and the system kills the app with
ForegroundServiceDidNotStartInTimeException, which the SDK cannot catch.
The SDK alone does not need Configuration.Provider. Since Android SDK 1.0.10-alpha06 it
initialises WorkManager with the default configuration the first time it needs a handle. That
covers the SDK and nothing else: WorkManager only honours Configuration.Provider on your
Application class, so no library can supply it for you.
Any other WorkManager user in your app needs Configuration.Provider alongside the block. Your
own workers, or a library such as @notifee/react-native or react-native-maps tile caching, can
call WorkManager.getInstance() before the SDK has, and that call throws
IllegalStateException: WorkManager is not initialized properly. Some libraries catch it and fail
silently. Notifee does exactly that when Android starts the app in the background to deliver a
notification-blocked broadcast: the event is dropped, and onBackgroundEvent never sees
APP_BLOCKED / CHANNEL_BLOCKED. Implementing the provider makes WorkManager initialise lazily on
the first call from any caller and keeps the cold-start saving:
// android/app/src/main/java/.../MainApplication.kt
import androidx.work.Configuration
class MainApplication : Application(), ReactApplication, Configuration.Provider {
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().build()
// ...
}
// android/app/build.gradle — libraries keep WorkManager implementation-scoped, so the app must
// declare it to see Configuration. Use the version your build already resolves
// (./gradlew :app:dependencies | grep work-runtime).
implementation("androidx.work:work-runtime:2.10.2")
If you cannot tell whether anything else uses WorkManager, add the provider anyway or leave the block out — both are safe. The block without the provider is safe only when the SDK is the sole WorkManager user.
Nothing else is required. The foreground service, the boot receiver, the activity/geofence receivers and every permission merge in from the AAR manifest — see Permissions.
iOS setup
1 — Call the launch hook in AppDelegate. The SDK reaches BGTaskScheduler.register(...),
which Apple requires before didFinishLaunching returns — and React Native starts JS only after
that returns. So one native line is unavoidable:
// ios/<App>/AppDelegate.swift
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import Tracker
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
TrackerLaunch.ready() // must be first, and native — a JS-driven ready() registers too late
// ... your existing React Native setup ...
return true
}
}
TrackerLaunch.ready() registers the SDK's background-task handlers — the half that must happen
inside the launch window — then loads an optional tracker.config.json from the app bundle (SDK
defaults if the file is absent or unparseable) and calls the native ready(config). Calling
Tracker.ready(config) from JS afterwards is safe and re-applies capture parameters — but
fields affecting background-task registration are fixed at launch on iOS.
Skipping the call is safe for tracking and costs you the 15-minute background backstop, which is
advisory — neither the continuous stream nor the relaunch primitives go through BGTaskScheduler.
You are told through the backgroundTasksNotRegistered error code, and a DEBUG build fails
ready() outright so the mistake cannot reach a release.
2 — Info.plist — usage strings, background modes, and the verbatim background-task
identifiers (a mismatch is a launch-window exception, not a degraded backstop):
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location to record trips.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app uses background location to keep recording a trip when it is not in the foreground.</string>
<key>NSMotionUsageDescription</key>
<string>This app uses motion activity to tell moving from stopped and save battery.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.fieldtrack360.tracker.backstop</string>
<string>com.fieldtrack360.tracker.sync</string>
</array>
The licence token is not an Info.plist entry any more — pass it via
Tracker.ready({ license }) (see Licensing).
3 — Podfile: platform floor and deployment-target gate. s.platforms alone only hard-fails a
clean install; an incremental install merely warns, and then dyld cannot load a framework whose
MinimumOSVersion is 17. Paste the gate into post_install (the sample carries it):
platform :ios, '17.0'
# ... inside your target's post_install, after react_native_post_install(...)
installer.aggregate_targets.each do |aggregate|
aggregate.user_project.native_targets.each do |native|
next unless native.symbol_type == :application
native.build_configurations.each do |cfg|
dt = (cfg.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] || '0').to_f
if dt < 17.0
raise "Tracker requires iOS 17.0+, but target #{native.name} (#{cfg.name}) is set to #{dt}. " \
"Raise IPHONEOS_DEPLOYMENT_TARGET to 17.0."
end
end
end
end
4 — Install the pods:
cd ios && pod install
The npm postinstall fetch must have run first — it is what fills ios/Frameworks/ inside the
package, and the podspec vendors ios/Frameworks/*.xcframework.
Expo
An Expo prebuild host cannot do the manual steps above by hand (android/ and ios/ are
generated and overwritten), so the bundled config plugin is the only integration path that
survives a prebuild. It performs exactly the manual steps and nothing more.
{
"expo": {
"plugins": [
["@fieldtrack360/react-native-tracker", {
"androidMapsApiKey": "YOUR_MAPS_API_KEY"
}]
]
}
}
| Plugin option | Type | Effect |
|---|---|---|
androidMapsApiKey | string | com.google.android.geo.API_KEY manifest meta-data — needed only for the map components. Omitted → no meta-data written. |
locationWhenInUse | string | Overrides NSLocationWhenInUseUsageDescription. |
locationAlways | string | Overrides NSLocationAlwaysAndWhenInUseUsageDescription. |
motionUsage | string | Overrides NSMotionUsageDescription. |
The plugin also: merges location/processing into UIBackgroundModes, merges both
BGTaskSchedulerPermittedIdentifiers, inserts TrackerLaunch.ready() into the AppDelegate
(Swift and Objective-C, idempotent), adds the JitPack repository to
settings.gradle, and raises minSdkVersion/compileSdkVersion to 26/36 (it never lowers them).
Expo Go cannot load this SDK — use a development build.
Licensing
Tracker is licensed software. Read the enforcement table below before you build a licence-gating screen on top of ready().
Getting a licence
Buy one at https://sdk.tracksdkhub.com/. Plans and prices live there; two things about a key change how you wire this package up:
- One key covers both platforms, so you supply exactly one token — which is why there is a single
licensefield. - A key is bound to one application identifier, named at checkout. Decide your
applicationId/ bundle identifier before you buy, and expect a separate key per app: on iOS a token issued against a different identifier failsready()withlicenseBundleMismatch.
The token arrives on screen at checkout and by email. Drop it into the config field below — there is nothing to register and no client secret.
Supplying the token
There is one way: TrackerConfig.license, applied at ready() — the same call on both
platforms.
await Tracker.ready({ license: TRACKER_LICENSE, /* …the rest of your config */ });
There is no iosLicense / androidLicense option and no native manifest or Info.plist route to
configure — one field reaches both platforms, so there is no second place that can disagree with it.
The license field is never persisted with the rest of your config: it is re-read on every
ready() call. This is the one field reset: false does not freeze, so an updated token always
wins over a stale one left on disk.
Token prefix. Tokens are currently issued with a
TRACKIT-prefix. If a token you were issued starts with anything else, check it against what the licence portal gave you before you cut a release.
What actually gets enforced
| iOS | Android | |
|---|---|---|
Token checked offline at ready() | yes — signature + bundle binding | no, removed in 1.0.1 |
Missing token blocks ready() | yes (licenseMissing) | no — ready() succeeds |
Wrong bundle / malformed token blocks ready() | yes (licenseBundleMismatch, licenseInvalid) | no |
| Server says revoked / expired | stops tracking (licenseRevoked, licenseExpired) | stops tracking (same two codes) |
Do not gate your UI on ready() returning a licence error — it never will on Android. A build
with no token at all starts and tracks normally there; the only enforcement left is the online
revocation check, which reports licenseRevoked / licenseExpired after startup. If you need
a hard "unlicensed" state on both platforms, own that check yourself.
licenseMissing and licenseBundleMismatch are therefore iOS-only in practice. They remain in
the ErrorCode union because the Android enum still declares them, but nothing on Android emits
them. licenseInvalid survives on both, with different force: a hard ready() failure on iOS, and
on Android a post-startup diagnostic from the online check that does not stop tracking.
Development
Debug and simulator builds on iOS are licence-waived, and Android now needs no token at all, so a
clone with no licence runs on both. The consequence is the same on either platform: a missing
token is invisible until an iOS release build, where ready() resolves
{ ok: false, code: 'licenseMissing' } and nothing downstream works. Test a release build before
you need one.
On Android the release-only integrity layer can additionally end an in-flight session with
deviceIntegrityBlocked; treat that event as a stop, not a warning.
Both platforms run the online revocation check. It never blocks startup and is fail-open — no network, an unverifiable reply or a server error all leave the tracker recording. See Online licence check for the readback surface.
Getting the token into JS
Keep it out of source control and out of the JS source. The example app uses
react-native-dotenv — a Babel plugin, so
there is nothing to link on either platform:
Create example/.env yourself — it is gitignored and there is no committed template, so nothing
carrying a token exists in the repository to copy or to leak:
# example/.env
TRACKER_LICENSE=TRACKIT-eyJ…
// babel.config.js
plugins: [
['module:react-native-dotenv', { moduleName: '@env', path: '.env', allowUndefined: true }],
],
import Tracker from '@fieldtrack360/react-native-tracker';
import { TRACKER_LICENSE } from '@env';
const res = await Tracker.ready({
license: TRACKER_LICENSE, // ← the native builder's .license(…)
trackingMode: 'adaptive', // ← every other builder field, same way
intervalMs: 1000,
});
if (!res.ok) {
// licenseMissing | licenseInvalid | licenseBundleMismatch — never silently continue.
console.warn(res.code, res.message);
}
allowUndefined: true matters: a clone with no .env must still build, because development is
licence-waived anyway. The omission surfaces where it is actionable — as licenseMissing from
ready() on a release build.
This keeps the token out of the repository, not out of the binary: it is inlined into the JS bundle at build time and readable by anyone who unpacks your app. That is expected. The token is bound to your application id and signed, so a copy is worth nothing in another app — it is still worth what you paid, so do not commit it.
Online licence check
Both platforms run an online check alongside the offline gate: the SDK asks the licence
server whether the token has been revoked or expired since it was issued, and stops tracking with
licenseRevoked / licenseExpired if so. You wire nothing up for it. What is Android-only is the
readback surface below — the licenseChecked event and the two Tracker.android.* calls.
It runs shortly after every ready() and every 12 hours after that, never blocks ready(), and is
fail-open — a server outage never stops a paying customer.
Tracker.onTrackerEvent((event) => {
if (event.type === 'licenseChecked') {
console.log(event.info.status, 'cached:', event.info.fromCache);
}
});
const info = await Tracker.android.licenseInfo(); // null = not checked yet, NOT a refusal
const fresh = await Tracker.android.checkLicense(); // force a check now
Branch on status, not on valid — valid is the server's coarse flag and collapses distinctions
status keeps. Only revoked and expired stop tracking; unknownKey, invalidKey,
packageMismatch and sdkMismatch are diagnostics about the vendor's ledger and tracking
continues.
Silence is not success. No event is emitted when the network failed or the response could not be verified, so the absence of a verdict tells you nothing — reading it as approval would mean reading a server outage as a valid licence.
iOS runs the same check but reports it differently. There is no licenseChecked event and no
status readback — Tracker.android.licenseInfo() / checkLicense() reject unsupportedOnPlatform
there. Instead iOS speaks up only to deactivate, through licenseDeactivated (an untyped status
string plus the admin's note) and the matching error code, and refuses start() until the server
reports the licence active again. Do not write one handler assuming both shapes.
LicenseInfo is { status, valid, packageName, checkedAt, ttlSeconds, reason, fromCache } — see
Types. status is one of 'active' | 'revoked' | 'expired' | 'unknownKey' | 'invalidKey' | 'packageMismatch' | 'sdkMismatch' | 'unrecognised' (unrecognised = a status this client does not
know yet).
Initialization and ready state
Initialization is two-phase:
| Phase | Where | What |
|---|---|---|
| A — launch hook | Native. TrackerLaunch.ready() (iOS, required) / TrackerLaunch.ready(context) (Android, optional) | Registers BGTaskScheduler handlers inside the launch window (iOS); restores filter state at onCreate timing (Android). Uses bundled/default config. |
| B — JS ready | await Tracker.ready(config?) | Applies your real TrackerConfig, verifies the licence, returns the first TrackerState. |
Everything else is gated on phase B: calls made before it fail with notReady. One exception on
iOS: getCurrentLocation() works before ready(), gated on location authorization alone — it
returns permissionDenied when that is denied, restricted or not yet requested, never prompts,
and does not store the fix.
import Tracker, { onStateChange, type TrackerState } from '@fieldtrack360/react-native-tracker';
const result = await Tracker.ready({
trackingMode: 'adaptive',
intervalMs: 1000,
activityRecognition: true,
persistDecisions: true,
});
if (result.ok) {
const state: TrackerState = result.value; // { isReady, isTracking, motionState, providerState, currentSessionId?, android? }
console.log('ready', state.isReady);
} else {
console.warn('ready failed', result.code, result.message); // never swallow this
}
Reading or observing readiness afterwards:
const state = await Tracker.getState(); // one-shot snapshot
const unsubscribe = onStateChange((s) => { // current value delivered on subscribe, then transitions
setIsReady(s.isReady);
setIsTracking(s.isTracking);
});
// on unmount:
unsubscribe();
Rules
ready()is idempotent and re-appliable; call it once at app start.config.resetdefaults totrue: config passed toready()is persisted and used by later sessions. Withreset: falsean existing persisted config wins in full and the object you pass is ignored. There is no livesetConfig().- On iOS, fields affecting background-task registration are fixed by the launch hook, not by a
later JS
ready(). - An invalid/undecodable config rejects with
invalidConfig(bridge fault); a licence or runtime refusal resolves{ ok: false, code, message }. - Subscriptions may be attached before
ready(); they simply deliver nothing until the SDK runs.
Permissions
The SDK declares the permissions; the host asks for them. The SDK never shows permission UI or rationale.
Android
You do not need to add these to your manifest — they merge in from the SDK's AAR:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="com.google.android.gms.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
plus the tracking foreground service (foregroundServiceType="location"), the boot receiver
(BOOT_COMPLETED, MY_PACKAGE_REPLACED) and the activity-transition / stationary-fence
receivers. REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is deliberately not declared — it is
Play-policy sensitive and must be your explicit choice.
Battery optimisation — Android only
Background tracking on OEM builds usually stops because of battery policy, not permissions.
Tracker.android.getBackgroundRestrictions() reads that state with no permission, and two methods
open the remedy:
const r = await Tracker.android.getBackgroundRestrictions();
// { ignoringBatteryOptimizations, backgroundRestricted, standbyBucket, degraded }
if (r.degraded || !r.ignoringBatteryOptimizations) {
// One-tap dialog when your manifest declares REQUEST_IGNORE_BATTERY_OPTIMIZATIONS;
// false (nothing shown) otherwise, or when the exemption is already held.
if (!(await Tracker.android.requestBatteryOptimizationExemption())) {
await Tracker.android.openBatteryOptimizationSettings(); // always works, two taps
}
}
ignoringBatteryOptimizations: false is the normal state of almost every app and not a fault on
its own — degraded (background-restricted, or standby bucket rare/restricted) ignores it
deliberately. Gate a warning on degraded; treat the exemption as a remedy to offer.
Declare REQUEST_IGNORE_BATTERY_OPTIMIZATIONS in your own manifest only if your app qualifies under
Play policy (a core feature that cannot work under Doze). Without it,
requestBatteryOptimizationExemption() resolves false rather than launching an intent that throws
on some OEMs and silently does nothing on others. Re-read getBackgroundRestrictions() when your
screen regains focus to reflect what the user changed.
Remote wake (FCM) — Android only
A process that an OEM battery manager has frozen, or that Android has parked in Doze, uploads nothing and notices nothing. The device cannot see that gap; your server can — an open shift with no upload for N minutes. Of the ways to wake an Android app, only a high-priority FCM data message gets through Doze, and it is one of the few things that lets an app start a foreground service from the background on API 31+.
Neither this package nor the SDK depends on Firebase. You own FCM (typically
@react-native-firebase/messaging) and hand the message to Tracker.android.wake():
// index.js — top level, next to AppRegistry.registerComponent
import messaging from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
import { Tracker } from '@fieldtrack360/react-native-tracker';
messaging().setBackgroundMessageHandler(async (message) => {
if (Platform.OS === 'android' && message.data?.type === 'fieldtrack_wake') {
await Tracker.android.wake();
}
});
Send the FCM token to your backend against the signed-in user and device (messaging().getToken()
and onTokenRefresh). wake() needs no ready() and is safe in a cold process. It decides whether
there is anything to do and resolves a WakeResult:
WakeResult | Meaning |
|---|---|
alive | The service was running. A fix and an upload drain were requested |
revived | A session was open with no service behind it; the service was started |
refused | The platform refused the start. The SDK's own restore path is queued and retries with backoff |
noSession | No session is open. Nothing was started — a wake never starts a session |
disabled | android.foregroundService is false; nothing to start |
timedOut | The session lookup did not finish within ~8 s; nothing was started |
locationOff | A session is open but location services are off; no fix was requested and nothing was started. An upload of anything already queued was still requested. Stop pushing this device until it next reports a point |
Each call is written to the session log as a Tracker line,
remote wake: <RESULT> (the SDK's own casing, e.g. NO_SESSION) — that line is how you tell whether a push reached the device at all.
What the server must send. Data-only and high priority. A notification block makes Android
display the message itself while the app is in the background, and the handler is never called.
Normal priority waits until Doze ends.
{ "message": {
"token": "<device FCM token>",
"android": { "priority": "high", "ttl": "300s" },
"data": { "type": "fieldtrack_wake" } } }
When to send it. On no upload or heartbeat for N minutes while a shift is open — never on no movement: a parked user legitimately stores nothing, and pushing at a healthy device wastes the high-priority budget. Back off (for example 3 → 6 → 12 → 30 min) and stop once the device's last-seen time moves. FCM may downgrade high-priority messages that rarely lead to anything the user sees; a revival posts the tracking notification, which counts.
What it cannot do. It cannot reach a force-stopped app — Android delivers no FCM to a stopped
package until the user opens it. Some OEM killers (ColorOS o-kill, some MIUI paths) amount to a
force-stop; there only the battery-optimisation exemption and
the OEM's auto-launch / background-activity toggle keep tracking alive.
Cold start. firebase-messaging adds FirebaseInitProvider to every cold start of your process,
inside the window before the tracking service must promote to the foreground. It is normally tens of
milliseconds; measure it on your slowest device. A JS background handler also pays for starting the
React Native runtime before wake() runs.
iOS — declared by you
The three usage strings, the two background modes and the two BGTaskSchedulerPermittedIdentifiers
in iOS setup. A missing usage string is a crash on first request; a missing task
identifier is a launch-window exception.
Runtime requests — use Tracker.permissions, not PermissionsAndroid
The native request shims feed the SDK's own permission arrays and preserve the ladder exactly.
Do not hand-roll the requests with PermissionsAndroid or a third-party permissions library — the
ordering rules below are enforced by the OS and are unrecoverable when broken.
The ladder, in order:
- Foreground —
requestForeground(). Fine + coarse in one request. Resolves the settledPermissionTier. - Your own rationale UI — a screen explaining why background access is needed. Required in practice, not by the API.
- Background —
requestBackground(). iOS shows the Always escalation at most once, and only from When-In-Use; Android cannot requestACCESS_BACKGROUND_LOCATIONin the same call as the foreground permissions and two denials make it permanently denied. Asking out of order, or for both at once, loses background tracking for that install with no in-app recovery. - Activity / motion —
Tracker.ios.requestMotion()on iOS (Motion & Fitness),Tracker.android.requestActivityRecognition()on Android ("Physical activity"). One rung, two OS names, same capability. - Notifications (Android 13+ only) —
Tracker.android.requestNotification(). Android runs the session inside a foreground service, and a foreground service must post a notification. A refusal does not stop capture, but the OS treats an app with no visible notification as a stronger kill candidate.
import Tracker, { type BackgroundRequest, type PermissionTier } from '@fieldtrack360/react-native-tracker';
import { Platform } from 'react-native';
async function runLadder(showRationale: () => Promise<boolean>) {
const tier: PermissionTier = await Tracker.permissions.requestForeground();
if (tier === 'none') {
// Denied. `shouldStopAsking(attempts)` tells you when the OS will not prompt again.
if (await Tracker.permissions.shouldStopAsking(2)) {
await Tracker.permissions.openAppSettings();
}
return;
}
if (!(await showRationale())) return;
const bg: BackgroundRequest = await Tracker.permissions.requestBackground();
switch (bg.kind) {
case 'alreadyGranted':
case 'granted':
break;
case 'needsForegroundFirst':
// Rung 1 is not satisfied — go back, do not ask again here.
break;
case 'needsSettings':
await Tracker.permissions.openAppSettings(); // explain what to tap before sending them
break;
case 'denied': // iOS: "Keep Only While Using" — capture degrades to foreground-only
case 'notApplicable': // Android: nothing to ask for on this API level
case 'prompt': // Android: the system dialog is showing / will show
break;
}
if (Platform.OS === 'ios') {
await Tracker.ios.requestMotion();
} else {
await Tracker.android.requestActivityRecognition();
await Tracker.android.requestNotification(); // no-op below Android 13
}
}
Denied, restricted, revoked, downgraded
| Situation | Behaviour |
|---|---|
| Foreground denied | getTier() is 'none'; start() resolves { ok:false, code:'permissionDenied' } |
| Background denied / downgraded mid-session | Capture degrades to foreground-only rather than stopping; a TrackerEvent error with backgroundPermissionMissing is emitted |
| Reduced (approximate) accuracy | getAccuracy() is 'approximate'; errors surface as coarseOnly. On iOS, Tracker.ios.requestTemporaryFullAccuracy(purposeKey) asks for a one-session upgrade (the purposeKey must exist in NSLocationTemporaryUsageDescriptionDictionary) |
| Location services off device-wide | locationDisabled; providerState reflects it. Turning them back on resumes the running session by itself on both platforms — do not stop()/start() around the toggle, which would end the track and start a new one |
| Permission revoked in Settings while running | Android restarts the process; the session resumes with whatever tier is left. iOS keeps the session and re-opens the location stream when the permission comes back. Read onProviderStateChange rather than caching a tier |
| OS will not prompt again | shouldStopAsking(attempts) → true; the only route is openAppSettings() |
example/src/screens/PermissionLadder.tsx is a complete, copy-ready implementation of all five
rungs with platform-specific copy for every failure state.
Location
Starting and stopping location updates
Location capture is bound to a session — start() begins capture, stop() ends it. There is
no separate "start location updates" call.
const started = await Tracker.start('morning-route'); // optional tag
if (!started.ok) console.warn(started.code, started.message);
// ... later
const stopped = await Tracker.stop();
Prerequisites, in order: ready() resolved ok → foreground permission granted (background too,
for capture that survives backgrounding) → start().
Observing locations
import { onTrackerEvent, onPoints, onLiveTrack } from '@fieldtrack360/react-native-tracker';
// Every accepted point, plus rejections, motion changes, errors…
const unsubEvents = onTrackerEvent((event) => {
switch (event.type) {
case 'location': console.log(event.point.latitude, event.point.longitude); break;
case 'locationRejected': console.log('rejected', event.decision.reason); break;
case 'motionChange': console.log('motion', event.state); break;
case 'error': console.warn(event.code, event.message); break;
}
});
// The stored point list for one session, re-delivered as it grows.
const unsubPoints = onPoints(sessionId, (points) => setPoints(points));
// The render-ready live frame for <LiveTrackMapView>. Capacity 1 — each frame replaces the last.
const unsubLive = onLiveTrack((update) => setUpdate(update));
Headless events — Android only
onTrackerEvent() needs a live JS subscriber, and there is none in a process the OS restarted
without a UI — after a reboot (the SDK's boot receiver) or a low-memory kill, the foreground
service comes back alone. registerHeadlessTask() is the path that reaches JS in that state.
It is not a second event vocabulary. params is the same TrackerEvent object
onTrackerEvent() delivers, and name is its type lifted out so you can switch on it directly.
Three things, all required:
// index.js — THE ONLY PLACE THIS CAN GO.
// A headless boot evaluates the bundle root and nothing else, so a registration inside a
// component, a screen or a provider never runs and the task start fails with
// "No task registered for key TrackerHeadless".
import { AppRegistry } from 'react-native';
import { registerHeadlessTask } from '@fieldtrack360/react-native-tracker';
import App from './src/App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);
// The handler receives a HeadlessEvent: { name: TrackerEvent['type'], params: TrackerEvent }.
// It registers under HEADLESS_TASK_KEY ('TrackerHeadless'), also exported.
registerHeadlessTask(async ({ name, params }) => {
// The task ends — and the native wake lock is released — when this promise settles.
// AWAIT EVERYTHING. Work left in flight is killed mid-request.
await postToServer(name, params);
});
// Both flags or nothing. stopOnTerminate: true stops the foreground service with the task, so
// there are no events left to dispatch and the collector is never installed.
await Tracker.ready({
android: { enableHeadless: true, stopOnTerminate: false },
});
// MainApplication.onCreate — OPTIONAL in general, REQUIRED here.
// It is the only callback that runs in a process the OS restarted without a UI, so it is the only
// place the collector can attach before the first event. The event stream has replay 0: an event
// emitted with nothing collecting is gone.
TrackerLaunch.ready(this)
On iOS registerHeadlessTask() is a no-op and enableHeadless is ignored — React Native has no
headless JS there. A terminated iOS app is woken by CoreLocation into a normal launch, so the usual
subscribers apply; the SDK's own storage (getPoints(), Tracker.geofences.getEvents()) remains
the source of truth for anything that happened while no JS was running. That holds on Android too:
headless delivery is best-effort, not a durability guarantee.
What it costs and where it stops:
- One short-lived task per event. React Native takes an untimed
PARTIAL_WAKE_LOCKwhile a task runs and releases it when the service's tasks drain. Every event type is delivered,locationincluded — at a fast cadence that is real battery. Keep the handler cheap. - Not delivered while the app is on screen. The foreground app already has
onTrackerEvent()giving it the same object; a headless task on top would double every handler. - Not delivered after a force-stop. Nothing in the app runs again until the user opens it.
- A start can be refused. Android's background-start rule rejects the service when no foreground service is running (tracking stopped, only a geofence receiver woke the process). Those events stay queued — 64 deep, drop-oldest, mirroring the native stream — and go out with the next accepted start, or are lost if the process dies first.
One-shot location
const fix = await Tracker.getCurrentLocation();
if (fix.ok) {
console.log(fix.value.latitude, fix.value.longitude, fix.value.accuracyM);
} else {
// iOS names the cause: fixTimeout is retryable; oneShotBusy, oneShotCircuitOpen and fixRejected
// are NOT — await the call in flight, or wait for the circuit to close. Android reports
// notReady / permissionDenied / locationDisabled / fixTimeout only.
console.warn(fix.code, fix.message);
}
On iOS the fix is also stored. The iOS SDK's one-shot feeds the ingest consumer by default, so with a session open this adds a judged point to it — nothing is bypassed, but a screen that asks "where am I" grows the user's track each time it opens. Android's one-shot is snapshot-only: never accepted, persisted, added to the odometer, or emitted.
Battery
import Tracker, { onBatteryChange, onBatteryThreshold } from '@fieldtrack360/react-native-tracker';
const battery = await Tracker.getBatteryInfo(); // { percent, isCharging, powerSource, isLow }
const unsubBattery = onBatteryChange((b) => setBattery(b)); // current value on subscribe, then changes
// Your own cutoffs. Fires ONCE per crossing, not on every reading: 22% → 19% fires
// { threshold: 20, crossing: 'below' } once; staying between thresholds stays silent.
const unsubThreshold = onBatteryThreshold([20, 5], ({ percent, threshold, crossing }) => {
if (crossing === 'below') console.warn(`battery ${percent}% dropped below ${threshold}%`);
});
percent and isCharging are null when not known (simulator, monitoring off) — never coalesce
them to 0 / false. onBatteryThreshold skips null readings, and the first reading only sets the
baseline, so nothing fires until the level actually moves across a threshold. isLow is the SDK's
fixed cutoff (percent <= 15).
Reading stored points
const points = await Tracker.getPoints({ sessionId, limit: 500 }); // default page size 500 both platforms
const count = await Tracker.getCount({ sessionId });
const metres = await Tracker.getOdometerMeters();
Plotting a finished track
const track = await Tracker.buildTrack(
{ sessionId, limit: 5000 },
{ snapToRoad: true, consolidateStops: true, zoom: 14 }
);
// track.warnings carries e.g. a truncation notice when the page came back full.
// Decode polylines with track.precision (default 6, NOT 5).
const geojson = await Tracker.exportGeoJson({ sessionId }); // coordinate order [lon, lat]
const polyline = await Tracker.exportPolylineJson({ sessionId });
Road snapping happens inside buildTrack, so changing it is a rebuild, not a redraw:
await Tracker.setOsrmSnapProvider({ baseUrl: 'https://router.example.com', profile: 'driving' });
// …buildTrack({...}, { snapToRoad: true })
await Tracker.clearRoadSnapProvider();
There is no default OSRM endpoint, and a snap failure falls back to raw geometry with a warning
(snapUnavailable).
Route projection
await Tracker.setActiveRoute([{ latitude: 23.02, longitude: 72.57 }, /* … */]); // [] clears it
const off = await Tracker.isOffRoute();
This projects the live puck only — it does not affect what is stored.
Configuration that shapes capture
The most-used TrackerConfig fields (full list in Types):
| Field | Type | Effect |
|---|---|---|
trackingMode | 'continuous' | 'adaptive' | 'motionOnly' | Cadence policy |
desiredAccuracy | 'high' | 'balanced' | 'low' | Provider accuracy request |
intervalMs / vehicularIntervalMs | number | Base and in-vehicle cadence |
accuracy.profile | 'strict' | 'balanced' | 'relaxed' | 'custom' | Fix-acceptance strictness |
activityRecognition | boolean | Motion classification on/off |
stopTimeoutMin, stationaryRadiusM | number | Stop detection |
maxDaysToPersist | number | Retention |
persistRawFixes / persistRawPoints / persistDecisions | boolean | Gate the diagnostic reads |
The Android notification
Android only. A session runs inside a foreground service, and Android requires a foreground service to post a persistent notification for as long as it lives — the one the user sees in the shade for the whole trip. You do not create it and you cannot hide it; you choose what it says. iOS has no counterpart (it shows the system location indicator instead), so there is nothing to configure there.
Set with nothing else, it reads "Tracking active" / "Recording your location" on a channel
named "Location tracking", with a generic pin icon. All five parts are ready() config, in the
android block:
await Tracker.ready({
license: TRACKER_LICENSE,
android: {
notificationTitle: 'Acme Field tracking your location',
notificationText: 'Location is being recorded for this shift',
notificationChannelId: 'acme_tracking',
notificationChannelName: 'Location tracking',
notificationSmallIconResName: 'ic_stat_tracker',
},
});
The icon is the one step that is not JavaScript. notificationSmallIconResName is the
filename of a drawable in your Android app, looked up by name at runtime — a name and not an
import, because the config is persisted to disk and a compiled resource id would point at a
different image after the next Android build. Create
android/app/src/main/res/drawable/ic_stat_tracker.xml and pass 'ic_stat_tracker' — no path, no
.xml.
What goes in that file matters more than it looks: from Android 5 on the OS alpha-masks a small
icon — every non-transparent pixel is repainted white and all colour is thrown away. Point this at
your launcher icon and you get a solid white square in the status bar, because a launcher icon is
opaque edge to edge. It has to be a white-on-transparent silhouette. Copy
example/android/app/src/main/res/drawable/ic_stat_tracker.xml and redraw the paths; it is a plain
24dp vector, and the comment at the top of it explains the mask again in situ.
A name that does not resolve is not fatal — the SDK falls back to the generic pin and writes a
logcat warning. A blank status-bar icon would fail the notification post, which would fail
startForeground, which would end the session, so the fallback is deliberate.
Two more things worth knowing before you ship:
- Changing
notificationChannelIdcreates a new channel. Android channels are permanent and user-owned: once someone has muted or restyled your old channel, a new id starts fresh and their choice does not follow. Pick an id in your first release and leave it alone.channelNameis the label they see in Settings and is safe to change at any time. - Importance is fixed at low, ongoing and silent — no sound, no vibration, and marked ongoing (Android 14+ lets the user swipe it away anyway; the service keeps running). Not configurable, and it is what keeps a notification the user did not ask for from behaving like one that wants attention.
The notification can also show the upload queue — for testing, not for shipping.
showSyncStatusInNotification: true replaces the description with a live readout and puts
syncNotificationSubText beside your title:
await Tracker.ready({
license: TRACKER_LICENSE,
android: {
notificationTitle: 'Acme Field tracking your location',
showSyncStatusInNotification: true,
syncNotificationSubText: 'Offline test build',
syncNotificationText: 'unsynced {pending} · last upload {age}',
},
});
{pending} is the number of rows queued and not yet uploaded; {age} is how long since the last
confirmed upload (21m ago, or never). Both are optional and may appear in any order, and an
unknown {token} is left as itself rather than blanked, so a typo shows on the notification instead
of vanishing. notificationTitle is never replaced — title, subtitle and description are three
slots, and the shade has to keep naming the app holding the foreground service.
Leave it off in a shipping app: "unsynced 42" means nothing to a user and something alarming. It
exists for the one test you cannot 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 is itself a drain trigger and would invalidate the test. The line is refreshed on the
watchdogIntervalMs tick, so a count that has not moved for one tick has not necessarily stalled.
A blank syncNotificationSubText — or a blank syncNotificationText while the flag is on — fails
ready() with invalidConfig; omit the key instead of sending an empty string.
Ask for the notification permission on Android 13+ via Tracker.android.requestNotification() —
rung 5 of the permission ladder.
A refusal does not stop capture, but a session with no visible notification is a stronger candidate
for the OS to kill.
Sessions
A session is one recording run: an id, a start time, an optional tag, an end time once closed, and the points captured between them. Exactly one session is open at a time.
ready() ─ ok ─▶ start(tag?) ─▶ (open session: points, live frames, odometer) ─▶ stop() ─▶ closed session
import Tracker, { type TrackSession } from '@fieldtrack360/react-native-tracker';
// Start
const started = await Tracker.start('afternoon-run');
if (!started.ok) {
switch (started.code) {
case 'notReady': /* call ready() first */ break;
case 'permissionDenied': /* run the permission ladder */ break;
case 'backgroundPermissionMissing': /* degraded — foreground-only capture */ break;
case 'locationDisabled': /* device location services are off */ break;
case 'licenseMissing':
case 'licenseBundleMismatch': /* iOS release build without a valid token */ break;
case 'licenseInvalid': /* iOS: hard failure. Android: diagnostic only */ break;
case 'licenseRevoked':
case 'licenseExpired': /* both platforms — the server withdrew it */ break;
default: console.warn(started.code, started.message);
}
} else {
const session: TrackSession = started.value; // { id, startedAtMs, tag?, isOpen: true, … }
}
// Current state
const open: TrackSession | null = await Tracker.currentSession();
const { isTracking, currentSessionId } = await Tracker.getState();
// History (both bounds optional; unbounded when omitted)
const sessions = await Tracker.getSessions(Date.now() - 7 * 864e5, Date.now());
// Stop
const stopped = await Tracker.stop();
if (stopped.ok) {
// Android carries the closed session; iOS may resolve `value` as null — do not assume a session.
const closed = stopped.value ?? null;
}
Lifecycle notes
- The session survives app backgrounding, app termination (Android, with
stopOnTerminate: false) and reboot (startOnBoot), because capture runs in the native service/background modes — not in JS. Your JS subscriptions do not. - A session the OS interrupted arrives as a
sessionInterruptedevent carrying the session. stop()on no open session resolves{ ok: true, value: null }-shaped or an error result depending on platform; checkokand treat a nullvalueas "nothing was open".- Geofences are independent of sessions: they need
ready()plus authorization, fire with no session open, and survive reboot.
MapView
Two Fabric components. Both are ordinary React Native views — style them, put them in a layout, mount and unmount them freely. Neither exposes imperative ref methods; both are driven purely by props. On Android both require a Google Maps API key (see Android setup) — it is needed only when you mount one of these components, not for tracking itself.
import { TrackMapView, LiveTrackMapView } from '@fieldtrack360/react-native-tracker';
<TrackMapView> — a finished track
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
track | Track | yes | — | The object returned by Tracker.buildTrack(). Serialised to the native view; the renderer never recomputes geometry |
options | object | no | SDK defaults | Renderer styling. Platform-divergent and intentionally unmerged — keys differ per platform (table below). Unknown keys are ignored; omitted keys keep the SDK default |
onArrowZoom | (zoom: number) => void | no | — | The renderer needs direction arrows rebuilt at a new zoom. Rebuild with buildTrack(query, { ...options, zoom }) and pass the new track — do not rescale |
…ViewProps | style, testID, etc. children is not supported |
options keys for <TrackMapView>:
| iOS | Android |
|---|---|
showArrows: boolean, showStopPins: boolean | showArrows: boolean, showStopMarkers: boolean |
arrowSize, stopPinSize: number | arrowSizePx: number |
basePathWidth, speedOverlayWidth: number | basePathWidth, speedOverlayWidth: number |
speedOverlayOpacity: number (0–1) | speedOverlayAlpha: number (0–255) |
cameraPadding, twoPointCameraPadding: number | cameraPaddingPx, cameraPaddingFallbackPx: number |
ongoingPulseSeconds: number | — |
gapLineWidth: number, gapDashLengths: number[] | — |
Colours (hex '#RRGGBB' / '#RRGGBBAA'): basePathColor, gapColor, speedBandGreen, speedBandYellow, speedBandRed | Colours not configurable yet |
<TrackMapView
track={track}
options={Platform.OS === 'ios'
? { showStopPins: false, basePathWidth: 6, basePathColor: '#1B1B1F' }
: { showStopMarkers: false, basePathWidth: 6 }}
/>
function TrackMap({ sessionId }: { sessionId: string }) {
const [track, setTrack] = useState<Track | undefined>();
const [zoom, setZoom] = useState<number | undefined>();
useEffect(() => {
let cancelled = false;
Tracker.buildTrack({ sessionId, limit: 5000 }, { snapToRoad: true, zoom })
.then((t) => { if (!cancelled) setTrack(t); })
.catch((e) => console.warn('buildTrack failed', e));
return () => { cancelled = true; };
}, [sessionId, zoom]);
if (!track) return <ActivityIndicator />;
return (
<TrackMapView
track={track}
onArrowZoom={setZoom} // rebuild at the new zoom
style={{ height: 320, borderRadius: 8 }}
/>
);
}
<LiveTrackMapView> — the live track
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
update | LiveTrackUpdate | no | — | The latest frame from onLiveTrack. Android rebuilds the render from it; iOS reads the native live stream directly and treats this as a liveness signal only |
followMode | 'none' | 'follow' | 'followBearing' | no | 'none' | Camera behaviour: none leaves the camera alone, follow keeps the puck centred, followBearing also rotates to the heading |
initialCentre | { latitude: number; longitude: number } | no | — | Camera centre before the first frame arrives |
options | object | no | SDK defaults | Live renderer styling. Platform-divergent — keys in the table below |
onFollowingChange | (isFollowing: boolean) => void | no | — | iOS only — fires when the user pans away from the puck. Android has no follow-state callback and never emits it |
…ViewProps | children is not supported |
options keys for <LiveTrackMapView>:
| iOS | Android |
|---|---|
tailWidth, headWidth: number | tailWidth, headWidth: number |
puckSize: number | puckSizePx: number |
haloFillOpacity, haloStrokeOpacity: number | showAccuracyHalo: boolean |
lookaheadMs, animationDurationMs: number | lookaheadMs, animationDurationMs: number |
followDistanceMeters, followPitchDegrees: number | followZoom, followTilt: number |
Colours (hex): tailColor, headColor, puckColor, haloColor | Colours not configurable yet |
The follow-camera framing has no lossless mapping between platforms (distance/pitch vs zoom/tilt),
which is why options is not unified.
function LiveMap() {
const [update, setUpdate] = useState<LiveTrackUpdate | undefined>();
const [following, setFollowing] = useState(true);
useEffect(() => onLiveTrack(setUpdate), []); // the unsubscribe fn IS the cleanup
return (
<LiveTrackMapView
update={update}
followMode={following ? 'followBearing' : 'none'}
initialCentre={{ latitude: 23.0225, longitude: 72.5714 }}
onFollowingChange={setFollowing}
style={{ flex: 1 }}
/>
);
}
Lifecycle
- Unmounting the view tears down the native map; it does not stop the session.
- The live subscription and the view are independent — subscribe in an effect, unsubscribe on unmount, and let the view render whatever frame it last received.
- Inside a
ScrollView, wrap the map so the scroll container does not swallow map gestures.
Geofences
Fences are independent of tracking: they need ready() and authorization, fire with no session
open, and survive reboot. The usable cap is 19 (one of the platform's 20 slots is reserved
for the SDK's stationary fence).
Do not reuse the stationary fence's id for a fence of your own — 'fieldtrack-stationary' on
Android, or whatever you set as android.stationaryGeofenceId. On Android an EXIT on that id is how
a parked, killed process learns the user has driven off, and the SDK recognises it by id alone, so a
fence of yours under the same id is treated as the wake fence too.
const added = await Tracker.geofences.add({
id: 'depot',
latitude: 23.0225,
longitude: 72.5714,
radiusM: 150,
// notifyOnEntry / notifyOnExit default to true.
// dwellAfterMs is iOS-only; setting it (or an explicit `false` on either notify flag) on
// Android resolves { ok:false, code:'invalidConfig' } naming the field.
onEnterEvent: 'depot_arrive',
onExitEvent: 'depot_leave',
});
const fences = await Tracker.geofences.list();
const one = await Tracker.geofences.get('depot');
await Tracker.geofences.remove('depot');
await Tracker.geofences.removeAll();
// getEvents() is the SOURCE OF TRUTH. A crossing delivered to a relaunched process never reaches a
// live JS subscriber, so poll this at launch and after backgrounding; live events are a convenience.
const crossings = await Tracker.geofences.getEvents({ geofenceId: 'depot', limit: 50 });
// Each crossing carries `eventName` — the fence's label for the direction that fired.
await Tracker.geofences.deleteEvents('depot');
// …or prune a range: every 'depot' crossing older than a week.
await Tracker.geofences.deleteEvents('depot', { toMs: Date.now() - 7 * 86_400_000 });
Sync
TrackerSync is a separate native module that uploads stored points to your endpoint.
import { TrackerSync } from '@fieldtrack360/react-native-tracker';
await TrackerSync.configure({
url: 'https://api.example.com/v1/points',
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
// Merged into the top level of every request body, beside the batch. Any JSON.
extraParams: { company_id: 42, device_label: 'van-7' },
autoSync: true,
batchSize: 200,
ios: { requiresNetworkConnectivity: true, backoffInitialSec: 5, backoffCeilingSec: 300 },
// syncLogs defaults to TRUE — configure() also sets up the session-log channel. See below.
android: { requiresUnmeteredNetwork: false, syncLogs: true }, // the gate is NOT the iOS policy
});
// Call after accepted points or at an app checkpoint even with autoSync on — Android does not
// auto-enqueue its worker on accepted-point events.
await TrackerSync.requestSync();
const result = await TrackerSync.syncNow();
switch (result.kind) {
case 'uploaded': console.log(result.count); break;
case 'empty': break;
case 'retry': console.log('will retry:', result.reason); break;
case 'authExpired': /* 401 — refresh credentials and reconfigure */ break;
case 'forbidden': /* Android only, 403 — reconfigure; the queue is intact */ break;
}
// Shapes: { kind: 'uploaded', count } · { kind: 'empty' } · { kind: 'retry', reason }
// · { kind: 'authExpired' } · { kind: 'forbidden' } (Android only)
const pending = await TrackerSync.pendingCount(); // TrackerResult<number>
// Both platforms. Only `httpResponse` arrives on Android; the other three types are iOS-only.
const unsub = TrackerSync.onSyncEvent((e) => console.log(e.type));
SyncConfig fields
| Field | Type | Default | Notes |
|---|---|---|---|
url | string | — (required) | Android: a relative url resolves against TrackerConfig.android.baseUrl; an absolute url always wins |
method | string | 'POST' | Android accepts only POST / PUT / PATCH; iOS passes any verb through |
headers | Record<string, string> | — | Static — call configure() again to rotate a token |
extraParams | Record<string, SyncParamValue> | — | Merged into the top level of the body — see below |
autoSync | boolean | true | Omitting it does not mean off. With false, drive uploads via syncNow() / requestSync() |
batchSize | number | 100 | Android requires 1..1000 |
ios.requiresNetworkConnectivity | boolean | SDK default | iOS gate: any connectivity |
ios.wipeOnAuthExpiry, ios.stopTrackingOnAuthExpiry | boolean | SDK default | iOS reaction to a 401 |
ios.backoffInitialSec, ios.backoffCeilingSec | number | SDK default | Retry backoff |
ios.autoSyncCoalesceSec | number | SDK default | Coalesces auto-sync triggers |
android.requiresUnmeteredNetwork | boolean | SDK default | Android gate: unmetered only — not the same policy as the iOS gate |
android.syncLogs | boolean | true | Also set up the session-log channel |
android.gzipRequestBody | boolean | false | Sends Content-Encoding: gzip. Turn on only once your endpoint decompresses — otherwise it answers 400 or stores compressed bytes |
android.allowCleartext | boolean | false | Permit http://. Without it an http:// url fails at runtime and retries forever. Loopback hosts (localhost, 127.0.0.1, ::1, 10.0.2.2) are exempt. Local development only |
android.timeouts | { connectMs?, readMs?, writeMs? } | 5000 / 30000 / 20000 | Each must be > 0 |
extraParams — your own fields in the request body
The upload body has two levels: location carries the batch, and everything in extraParams sits
beside it, at the top level, before location, in the order you wrote the keys.
await TrackerSync.configure({
url: 'https://api.example.com/v1/location/batch',
extraParams: {
user_id: 'u-42',
company_id: 7, // a number stays a number — nothing is stringified
debug: false,
device: { label: 'van-7', app: '3.2.0' }, // maps and lists nest
},
});
{
"user_id": "u-42",
"company_id": 7,
"debug": false,
"device": { "label": "van-7", "app": "3.2.0" },
"location": [ { "uuid": "…" } ]
}
This is for what belongs to the request rather than to any point — a tenant id, a device label,
an API version — and that a header cannot carry because your endpoint reads its body. It is static
config, like headers: a rotating token belongs in a fresh configure() call. With extraParams
unset the body is byte-identical to a build without it, so an existing backend needs no change.
locationis reserved. It is the batch itself, and both SDKs refuse the key.nullis the one value that does not mean the same thing on both platforms. iOS models it and encodes JSONnull; the Android SDK has no null value — "omit the key instead" — so a null-valued key is dropped there. Anullinside an array would renumber the array if dropped, so Android rejects that asinvalidConfigrather than silently shifting the elements. Send a sentinel when the key must reach both bodies.- Android caps nesting at 10 levels and rejects an unserializable value at
configure()time, naming the key, rather than failing hours later mid-drain.
forbidden is Android only (the iOS SDK has no such case) and is deliberately not folded onto
authExpired. A 401 is a teardown — Android stops tracking, clears the queue and forgets the config
— while a 403 keeps tracking running and every queued row intact, and only halts the retry loop.
Recover from it by calling configure() again with a credential allowed to write that endpoint; a
re-login and a wipe would be the wrong reaction to what is usually a scope or permission problem.
onSyncEvent runs on both platforms, but the event vocabulary does not match: httpResponse is
the only case this plugin forwards on Android, so uploaded / retryScheduled / authExpired
never fire there — read upload outcomes from syncNow() / pendingCount() instead. (Android SDK
1.0.6 added a NetworkAvailable case, announcing a drain triggered by reconnection; it is dropped
in the bridge rather than given a JS event type iOS could never emit, and the drain it announces
still reports itself through httpResponse.) Android also replays the last exchange to a new
subscriber, so the first event after subscribing may describe a drain that finished earlier; iOS
replays nothing. TrackerSync.ios.onSyncEvent remains as a
deprecated alias for the same function.
Session logs — Android only
Points answer where the device was. Session logs answer why there is nothing there. New at the
Android 1.0.10 pin and reached through TrackerSync.android.* — the iOS SDK has no counterpart,
so every method here rejects unsupportedOnPlatform on iOS.
On by default since Android SDK 1.0.10-alpha02.
TrackerSync.configure()now derives a log channel by itself — the origin of your pointsurlplusv1/logs/batch, inheritingdevice_idfromextraParamsand reusing the points headers — and it also records the SDK's own internal log output, in release builds too. A backend serving points should expect log traffic from any host that has not opted out. Opt out withandroid: { syncLogs: false }on the points config. Deriving the channel is never fatal to points: if it cannot be built (nodevice_id, an unparseable url) the SDK logs why and points are unaffected.
import { TrackerSync } from '@fieldtrack360/react-native-tracker';
// Optional since Android SDK 1.0.10-alpha02 — configure() already derived this exact channel.
// Call it to override the derived default: a different endpoint, credential, level or interval.
// An explicit call wins PERMANENTLY; later configure() calls leave it alone rather than
// re-deriving over it. Unlike the derived default it rejects an invalid config.
await TrackerSync.android.configureLogs();
await TrackerSync.android.log({
level: 'warn',
tag: 'Checkout',
message: 'driver marked delivered with no fix in 4 min',
code: 'NO_FIX',
data: JSON.stringify({ orderId: 'A-4471' }), // a JSON structure, as a string
});
await TrackerSync.android.logLifecycle('session_start');
const { configured, endpoint } = await TrackerSync.android.logStatus();
const waiting = await TrackerSync.android.pendingLogCount(); // TrackerResult<number>
const entries = await TrackerSync.android.getLogs({ limit: 50 });
const result = await TrackerSync.android.syncLogsNow(); // shipped/empty/retry/rejected
await TrackerSync.android.disableLogSync();
It cannot lose you a position. The buffer is a separate database from the one holding points, so a credential failure on the log endpoint cannot reach a stored position — which is what makes it safe to point at a different endpoint with different credentials.
syncLogsNow() answers shipped / empty / retry / rejected — not syncNow()'s four.
rejected is terminal for this channel and carries the status code: 401/403 means the credential,
404/405/501 means there is no endpoint at that URL. Either way the buffer is kept and tracking is
untouched; recover with a fresh configureLogs().
LogSyncConfig fields — every field is optional; configureLogs() with no argument uses all
defaults.
| Field | Type | Default | Notes |
|---|---|---|---|
url | string | points origin + v1/logs/batch | A relative path resolves the same way |
deviceId | string | inherited from points extraParams.device_id | A different id produces two unrelated datasets |
method | string | 'POST' | |
headers | Record<string, string> | points headers | |
autoSync | boolean | true | 15-minute heartbeat. false → drive via syncLogsNow() / requestLogSync() |
level | 'debug' | 'info' | 'warn' | 'error' | 'info' | Minimum severity kept |
types | ('event' | 'decision' | 'message' | 'lifecycle')[] | ['event', 'lifecycle', 'message'] | See the decision warning below |
bufferCapacity | number | 5000 | Rows held before the oldest are dropped |
retentionHours | number | 72 | |
batchSize | number | 200 | Endpoint ceiling 500; a larger batch is rejected permanently |
uploadIntervalMinutes | number | 15 | 15 is also the floor (WorkManager limit) |
nudgeLevel | LogLevel | null | 'warn' | An entry at this level or worse uploads straight away. null disables |
nudgeCooldownMs | number | 30000 | Throttle on the nudge; a burst inside it is deferred, not dropped |
requiresUnmeteredNetwork | boolean | SDK default | |
gzipRequestBody | boolean | true | The inverse of the points channel's default |
allowCleartext | boolean | false | |
timeouts | { connectMs?, readMs?, writeMs? } | SDK defaults | |
extraParams | Record<string, SyncParamValue> | — | Same rules as the points channel |
LogType: event = TrackerEvents (permission/provider changes, errors), lifecycle =
session/service/process phases, message = your log() calls, decision = the SDK's per-fix
decision log.
Filtering happens at record time, not upload time (level, types), so raising level later
does not retroactively drop what is already buffered. Uploads are a 15-minute heartbeat, except
that an entry at nudgeLevel ('warn' by default) asks for one straight away, throttled to one
per nudgeCooldownMs. nudgeLevel: null turns that off.
typesdefaults to['event', 'lifecycle', 'message']—'decision'is not in it, and should not be for a fleet. The SDK's per-fix decision log is roughly 29 000 entries per device per shift. Turn it on for a named device with a ticket open. The firstconfigureLogs()that enables it moves the watermark to the newest row, so an opt-in ships the next drive rather than the last three days.
getLogs() returns entries newest-first. record.data is the raw JSON string as stored —
parse it yourself, in a try/catch — and record.elapsedRealtimeNanos is a string because a
monotonic nanosecond stamp passes what a JS number holds exactly after about 104 days of uptime.
TrackerSync.android.onLogEvent(cb) is this channel's own stream, separate from onSyncEvent.
Only httpResponse arrives, as on the points stream.
The SDK writes entries of its own, without a log() call from you. Two are lifecycle entries
worth knowing about, because they are what answer "this device never records anything":
'device_location'— the device's location permissions and providers, on every start attempt. Astart()refused at the permission gate opens no session, so this one is filed against the device with a nullsessionId. It is'warn'whenever the state would stop or degrade tracking, so it survives the defaultlevel: 'info'. A granted start writes one line per session; a refused start writes every time. Since Android SDK1.0.10-alpha06it also carries the battery-policy state —battery_optimised,background_restricted,standby_bucketindata(eachnullwhen unreadable) — so the same row explains a session that started fine and stopped an hour later. It escalates to'warn'ondegraded, never on the exemption alone.'device_motion'— the device's motion hardware, once per session. It explains a gap inside a session, where'device_location'explains a session that never opened.
Beyond those, the SDK's own internal log output is recorded too. In a release build that is its
warnings only — the per-fix commentary is compiled out of the release artifact, so level: 'debug'
does not resurrect it there.
Server contract: v1/logs/batch, documented in the Android SDK's docs/APP-LOG-API.md.
Complete API reference
Methods below are on the default export Tracker (the top-level methods and subscriptions are also named exports). Methods return
Promises; subscriptions return an unsubscribe function; registerHeadlessTask returns void.
TrackerSync is a separate named export. Unless stated, availability is both platforms.
import Tracker, { TrackerSync, TrackMapView, LiveTrackMapView, onTrackerEvent } from '@fieldtrack360/react-native-tracker';
Lifecycle
| Method | Parameters | Returns | Notes |
|---|---|---|---|
ready(config?) | config?: TrackerConfig | Promise<TrackerResult<TrackerState>> | Phase B init. Rejects invalidConfig on undecodable config. iOS background-task fields are fixed at launch |
start(tag?) | tag?: string | Promise<TrackerResult<TrackSession>> | Opens a capture session |
stop() | — | Promise<TrackerResult<TrackSession | null>> | Android carries the closed session; iOS may resolve value as null |
getState() | — | Promise<TrackerState> | One-shot snapshot |
Reads
| Method | Parameters | Returns | Notes |
|---|---|---|---|
getPoints(query?) | query?: PointQuery | Promise<TrackPoint[]> | Default page size 500 on both platforms |
getCount(query?) | query?: PointQuery | Promise<number> | |
getOdometerMeters() | — | Promise<number> | |
getSessions(fromMs?, toMs?) | fromMs?: number, toMs?: number | Promise<TrackSession[]> | Both bounds optional |
currentSession() | — | Promise<TrackSession | null> |
Current location
| Method | Parameters | Returns | Notes |
|---|---|---|---|
getCurrentLocation() | — | Promise<TrackerResult<TrackFix>> | iOS also stores the fix on the open session (not before ready()); Android reports it only. iOS failures name themselves — fixTimeout is retryable, oneShotBusy / oneShotCircuitOpen / fixRejected are not |
Plotting
| Method | Parameters | Returns | Notes |
|---|---|---|---|
buildTrack(query?, options?) | PointQuery, TrackOptions | Promise<Track> | Decoded track tree. Read track.precision (default 6) when decoding polylines; check track.warnings |
exportPolylineJson(query?, options?) | same | Promise<string> | SDK polyline JSON |
exportGeoJson(query?, options?) | same | Promise<string> | GeoJSON; coordinate order [lon, lat] |
Road snapping / live surface
| Method | Parameters | Returns | Notes |
|---|---|---|---|
setOsrmSnapProvider(config) | { baseUrl: string; profile?: string } | Promise<void> | No default endpoint. On failure buildTrack falls back to raw geometry |
clearRoadSnapProvider() | — | Promise<void> | |
setActiveRoute(points) | { latitude, longitude }[] | Promise<void> | Projects the live puck only; [] clears |
isOffRoute() | — | Promise<boolean> |
Diagnostics
| Method | Parameters | Returns | Notes |
|---|---|---|---|
getRawFixes(sessionId) | string | Promise<RawFix[]> | Gated by config.persistRawFixes |
getRawPoints(sessionId) | string | Promise<RawPoint[]> | Gated by config.persistRawPoints |
getDecisions(sessionId?, limit?, offset?) | string, number, number | Promise<FixDecision[]> | Gated by config.persistDecisions |
offerFix(fix) | TrackFix | Promise<void> | Injects a fix; does not bypass validation |
getSensors() | — | Promise<DeviceSensors> | |
getBatteryInfo() | — | Promise<BatteryInfo> | One-shot; onBatteryChange for a live value |
Tracker.permissions
| Method | Parameters | Returns | Notes |
|---|---|---|---|
getTier() | — | Promise<PermissionTier> | 'none' | 'foreground' | 'always' |
getAccuracy() | — | Promise<'approximate' | 'precise'> | |
shouldStopAsking(attempts) | attempts: number | Promise<boolean> | true → only Settings can move it |
requestForeground() | — | Promise<PermissionTier> | Fine + coarse in one request |
requestBackground() | — | Promise<BackgroundRequest> | Only prompts when the SDK says it is promptable |
getBackgroundRequest() | — | Promise<BackgroundRequest> | Read without prompting |
openAppSettings() | — | Promise<boolean> | Opens the app's system settings page |
BackgroundRequest = { kind: 'alreadyGranted' | 'granted' | 'denied' | 'notApplicable' | 'needsForegroundFirst' | 'prompt' | 'needsSettings' }
(granted/denied are iOS-only; notApplicable/prompt are Android-only.)
Tracker.geofences
| Method | Parameters | Returns | Notes |
|---|---|---|---|
add(fence) | Geofence | Promise<TrackerResult<Geofence>> | dwellAfterMs, or an explicit notifyOnEntry/Exit: false, → invalidConfig on Android |
list() | — | Promise<Geofence[]> | |
get(id) | string | Promise<Geofence | null> | |
remove(id) | string | Promise<TrackerResult<boolean>> | |
removeAll() | — | Promise<TrackerResult<number>> | |
getEvents(opts?) | GeofenceEventsQuery | Promise<GeofenceCrossing[]> | Source of truth. fromMs/toMs honoured on both |
deleteEvents(geofenceId?, window?) | string?, GeofenceEventsWindow | Promise<number> | Omit both to delete all. window is { fromMs?, toMs? }, honoured on both |
Tracker.ios — rejects unsupportedOnPlatform on Android
| Method | Parameters | Returns |
|---|---|---|
changePace(isMoving) | boolean | Promise<TrackerResult<void>> |
requestMotion() | — | Promise<MotionAuthorization> |
getMotionAuthorization() | — | Promise<MotionAuthorization> |
requestTemporaryFullAccuracy(purposeKey) | string | Promise<'approximate' | 'precise'> |
Tracker.android — rejects unsupportedOnPlatform on iOS
| Method | Parameters | Returns |
|---|---|---|
integrity() | — | Promise<IntegrityReport> — last evaluation, cheap. Check waived first: on a debuggable build nothing is probed |
checkIntegrity() | — | Promise<IntegrityReport> — forces a re-evaluation (reads /proc, the package list, a loopback socket); keep it behind a user action |
licenseInfo() | — | Promise<LicenseInfo | null> — cached online-licence verdict; null = not checked yet, not a refusal. No network |
checkLicense() | — | Promise<LicenseInfo | null> — forces a server check; fail-open (returns the cached verdict when offline) |
hasActivityRecognition() | — | Promise<boolean> |
requestActivityRecognition() | — | Promise<boolean> |
hasNotificationPermission() | — | Promise<boolean> |
requestNotification() | — | Promise<boolean> (no-op below Android 13) |
getBackgroundRestrictions() | — | Promise<BackgroundRestrictions> |
openBatteryOptimizationSettings() | — | Promise<boolean> |
requestBatteryOptimizationExemption() | — | Promise<boolean> — false and nothing shown when REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is not in your manifest or the exemption is already held |
wake() | — | Promise<WakeResult> — remote wake from a high-priority FCM data message; needs no ready(), never starts a session. See Remote wake (FCM) |
Subscriptions
Each returns an unsubscribe function — call it on unmount. Buffering is native and fixed: the
live-track stream has capacity 1 (each frame replaces the last), the event stream depth 64 with
drop-oldest, both with replay 0. onStateChange / onProviderStateChange / onBatteryChange
deliver the current value on subscribe.
| Function | Callback | Notes |
|---|---|---|
onTrackerEvent(cb) | (event: TrackerEvent) => void | The 22-case union below |
onLiveTrack(cb) | (update: LiveTrackUpdate) => void | Feed straight into <LiveTrackMapView> |
onPoints(sessionId, cb) | (points: TrackPoint[]) => void | Stored points for one session |
onStateChange(cb) | (state: TrackerState) => void | |
onProviderStateChange(cb) | (state: ProviderState) => void | |
onBatteryChange(cb) | (battery: BatteryInfo) => void | Both platforms |
onBatteryThreshold(thresholds, cb) | thresholds: number[], (c: BatteryThresholdCrossing) => void | Fires once per crossing of each threshold; null readings skipped. See Battery |
registerHeadlessTask(task) | (event: HeadlessEvent) => Promise<void> | Android only. Events with no UI process — see Headless events. No unsubscribe: register once in index.js |
type TrackerEvent =
| { type: 'location'; point: TrackPoint }
| { type: 'locationRejected'; decision: FixDecision }
| { type: 'motionChange'; state: MotionState; point: TrackPoint | null }
| { type: 'activityChange'; activity: ActivityType; confidence: number }
| { type: 'enabledChange'; enabled: boolean }
| { type: 'providerChange'; state: ProviderState; previous?: ProviderState } // previous: Android only; absent on the first observation
| { type: 'heartbeat'; atMs: number }
| { type: 'powerSaveChange'; enabled: boolean }
| { type: 'sessionInterrupted'; session: TrackSession }
| { type: 'diagnostic'; message: string }
| { type: 'error'; code: ErrorCode; message: string }
| { type: 'geofenceEnter'; crossing: GeofenceCrossing }
| { type: 'geofenceExit'; crossing: GeofenceCrossing }
| { type: 'geofenceDwell'; crossing: GeofenceCrossing } // iOS only
| { type: 'geofenceAdded'; geofence: Geofence } // both platforms; radiusM is the CLAMPED value
| { type: 'geofenceRemoved'; geofenceId: string } // both platforms; removeAll() emits one per fence
| { type: 'batteryChange'; battery: BatteryInfo }
| { type: 'licenseDeactivated'; status: string; reason: string | null } // iOS only
| { type: 'integrityChange'; report: IntegrityReport } // Android only, release builds; fires on change
| { type: 'licenseChecked'; info: LicenseInfo } // Android only; not emitted when the check could not verify
| { type: 'powerReport'; report: PowerReport } // Android only; every android.powerReportIntervalMin (default 15, 0 = off)
| { type: 'trackingGap'; durationSec: number; distanceMeters: number }; // iOS only
Platform-only cases: geofenceDwell, licenseDeactivated, trackingGap (iOS);
integrityChange, licenseChecked, powerReport (Android). TrackerEventType is TrackerEvent['type'].
A case that is absent on a platform simply never arrives — do not build a liveness assumption on one.
TrackerSync
| Method | Parameters | Returns | Notes |
|---|---|---|---|
configure(config) | SyncConfig | Promise<void> | Rejects invalidConfig on bad JSON, an unparseable iOS url, a failed Android SyncConfig.validate() (cleartext url, verb outside POST/PUT/PATCH, batchSize out of range), or an extraParams value the platform cannot encode |
requestSync() | — | Promise<void> | Call after accepted points even with autoSync on |
syncNow() | — | Promise<SyncResult> | { kind: 'uploaded', count } / { kind: 'empty' } / { kind: 'retry', reason } / { kind: 'authExpired' }, plus { kind: 'forbidden' } (Android only) |
pendingCount() | — | Promise<TrackerResult<number>> | |
onSyncEvent(cb) | (e: SyncEvent) => void | () => void | Both platforms; only httpResponse is emitted on Android |
ios.onSyncEvent(cb) | (e: SyncEvent) => void | () => void | Deprecated alias for onSyncEvent |
Session logs — Android only, Android SDK 1.0.10. Every one of these rejects
unsupportedOnPlatform on iOS. Since Android SDK 1.0.10-alpha02 the channel is set up by
TrackerSync.configure() itself unless android.syncLogs is false.
| Method | Parameters | Returns | Notes |
|---|---|---|---|
android.configureLogs(config?) | config?: LogSyncConfig (default {}) | Promise<void> | Overrides the channel configure() derived, and wins permanently over later configure() calls. With no argument, follows the points endpoint (v1/logs/batch) and inherits its device_id and headers. Rejects invalidConfig on bad JSON or a failed LogSyncConfig.validate() |
android.disableLogSync() | — | Promise<void> | Stops shipping, cancels the worker; the buffer is kept |
android.log(entry) | { level, tag, message, code?, data? } | Promise<void> | data must be a JSON structure as a string. An entry at nudgeLevel or worse asks for a drain immediately |
android.logLifecycle(phase, tag?) | LifecyclePhase | string, string | Promise<void> | tag defaults to "Host". 'device_location' and 'device_motion' are written by the SDK itself — don't send them |
android.getLogs(opts?) | { sessionId?, limit?, offset? } | Promise<LogRecord[]> | Newest-first; defaults limit 200 / offset 0 |
android.pendingLogCount() | — | Promise<TrackerResult<number>> | A different queue from pendingCount() — the two numbers are unrelated |
android.syncLogsNow() | — | Promise<LogSyncResult> | { kind: 'shipped', count } / { kind: 'empty' } / { kind: 'retry', reason, retryAfterMs? } / { kind: 'rejected', statusCode } — not the points channel's four |
android.requestLogSync() | — | Promise<void> | No-op, not an error, when the channel is unconfigured or terminally rejected |
android.logStatus() | — | Promise<{ configured: boolean; endpoint?: string }> | |
android.onLogEvent(cb) | (e: SyncEvent) => void | () => void | This channel's own stream; only httpResponse arrives |
Components
TrackMapView and LiveTrackMapView — see MapView.
Errors
Bridge rejections use these codes: invalidConfig (bad arguments / undecodable JSON),
unsupportedOnPlatform (wrong-platform namespace), internalError (an unexpected native throw).
Domain failures resolve with an ErrorCode (33 values):
- Shared (22):
notReady,permissionDenied,backgroundPermissionMissing,coarseOnly,locationDisabled,fgsStartRefused,fixTimeout,storageFull,storageReset,trackerDead,invalidConfig,motionDetectionDegraded,snapUnavailable,internalError,licenseMissing,licenseInvalid,licenseBundleMismatch,licenseRevoked,licenseExpired,geofenceRegistrationFailed,geofenceRemovalFailed,geofenceLimitReached - iOS only (4):
oneShotBusy,oneShotCircuitOpen,fixRejected— all three fromgetCurrentLocation(), and none of them worth retrying — plusbackgroundTasksNotRegistered - Android only (7):
playServicesUnavailable,notificationHidden,noActivity,deviceIntegrityBlocked,licenseUnknown,licensePackageMismatch,licenseSdkMismatch
The three geofence* codes are shared as of iOS SDK 1.0.5, which added them to its
own enum; they were Android-only before it. backgroundTasksNotRegistered means a BGTaskScheduler
launch handler could not be installed — an identifier missing from
BGTaskSchedulerPermittedIdentifiers, or TrackerLaunch.ready() missing from the AppDelegate. It
is not a tracking failure: you lose the advisory 15-minute backstop and nothing else. fgsStartRefused exists in the iOS enum but is never
emitted there. deviceIntegrityBlocked is
release-only (the integrity layer is waived on debuggable installs) and ends an in-flight
session — treat it as a stop, not a warning.
Types
type TrackerResult<T> = { ok: true; value: T } | { ok: false; code: ErrorCode; message: string };
type TrackerState = {
isReady: boolean; isTracking: boolean; motionState: MotionState;
providerState: ProviderState; currentSessionId?: string;
/** Android only, absent on iOS. `effectiveTrackingMode` is the mode actually in force, which is
* NOT always the one you configured: on `motionQuality: 'poor'` the SDK rewrites it to
* `'continuous'`, and nothing else reports that. Reading it costs less than blaming the battery
* on a mode that is not running. */
android?: { motionQuality: MotionQuality; effectiveTrackingMode: TrackingMode };
};
type ProviderState = {
permissionTier: PermissionTier; accuracyAuthorization: 'approximate' | 'precise'; powerSave: boolean;
ios?: { locationServicesEnabled: boolean; significantLocationChangeAvailable: boolean; regionMonitoringAvailable: boolean };
android?: { gpsEnabled: boolean; networkEnabled: boolean; fusedAvailable: boolean };
};
type DeviceSensors = {
motionQuality: 'full' | 'degraded' | 'poor';
ios?: { activityRecognition; stepCounting; significantLocationChange; regionMonitoring: boolean };
android?: { accelerometer; gyroscope; magnetometer; significantMotion; stepDetector; stepCounter; barometer; rotationVector: boolean };
};
/** Both platforms. `percent` / `isCharging` are null when NOT KNOWN — never coalesce to 0 / false.
* `isLow` is the SDK's own derivation (percent != null && percent <= 15). On Android it is the
* same reading stamped on every stored point; iOS `TrackPoint` carries no battery at all. */
type BatteryInfo = {
percent: number | null; isCharging: boolean | null;
powerSource: 'none' | 'ac' | 'usb' | 'wireless' | 'dock' | 'unknown'; isLow: boolean;
};
/** Android only. `ignoringBatteryOptimizations: false` is the normal state, not a fault — `degraded`
* (background-restricted, or standby bucket >= 40) is the SDK's verdict. `standbyBucket` is null
* below API 28. */
type BackgroundRestrictions = {
ignoringBatteryOptimizations: boolean; backgroundRestricted: boolean;
standbyBucket: number | null; degraded: boolean;
};
type TrackFix = {
timeMs; monotonicNanos; receivedAtMonotonicNanos: number;
latitude; longitude; accuracyM: number; altitudeM?; verticalAccuracyM?: number;
speedMps; bearingDeg: number; hasSpeed; hasBearing: boolean; provider: string; isMock: boolean;
speedAccuracyMps?; bearingAccuracyDeg?: number; android?: { satelliteCount?: number };
};
type TrackPoint = {
id: number; uuid; sessionId; localDate; timezone; provider: string;
timeMs; monotonicNanos; latitude; longitude; accuracyM; speedMps; bearingDeg;
activityStartTimeMs; odometerM: number;
hasSpeed; hasBearing: boolean; movementStatus: 'steady' | 'moving';
altitudeM?; batteryPct?: number; detectedActivity?: ActivityType; extras?: string; acceptReason: string;
isCharging?; isMock?: boolean; // both platforms; null/absent = NOT KNOWN
ios?: { isSignificantStop?: boolean };
};
type TrackSession = {
id: string; startedAtMs: number; endedAtMs?: number; tag?: string;
configSnapshot?: string; isOpen: boolean; android?: { startedAtElapsedNanos?: number };
};
type PointQuery = { sessionId?: string; fromMs?: number; toMs?: number; limit?: number; offset?: number };
type Track = {
version: number; sessionId?: string; generatedAtMs: number; from: number; to: number;
timezone: string; precision: number; // read this when decoding — default 6
bounds?: Bounds; stats: TrackStats; encodedPolyline: string;
points: TrackJsonPoint[]; segments: TrackSegment[]; stops: StopNode[]; arrows: ArrowAnchor[];
warnings: string[];
};
type TrackOptions = {
zoom?; stopRadiusM?; stopMinDwellSec?; splineSpacingM?; bezierMinAngleDeg?; bezierCutbackM?;
snapMaxOffRoadM?; polylinePrecision?; arrowMinSegmentM?; simplifyEpsilonM?: number;
includeRawPoints?; consolidateStops?; snapToRoad?: boolean;
smoothing?: 'none' | 'spline' | 'bezier'; speedBandsKmph?: number[];
// Android SDK 1.0.10. Bounds on the road geometry INJECTED BETWEEN two snapped fixes —
// a larger claim than snapMaxOffRoadM's "may this point move onto the road": a wrong point is
// metres wrong, a wrong span is a confident line down streets nobody drove. iOS has no
// counterpart; sent there, they are ignored rather than an error.
android?: { snapMaxDetourFactor?: number; snapBridgeFlatM?: number };
};
type LiveTrackUpdate = {
sessionId: string; sequence: number; precision: number;
frozenTailPolyline: string; liveHead: GeoPoint[]; puck?: PuckState;
};
type Geofence = {
id: string; latitude: number; longitude: number; radiusM: number;
notifyOnEntry?: boolean; notifyOnExit?: boolean; // default true
dwellAfterMs?: number; // iOS only
onEnterEvent?: string; onExitEvent?: string; // Android derives one when omitted
};
type GeofenceCrossing = {
geofenceId: string; transition: 'enter' | 'exit' | 'dwell';
timeMs?; latitude?; longitude?; radiusM?: number; // absent on Android live events
eventName?: string; // absent on a dwell / an unlabelled iOS fence
};
type GeofenceEventsQuery = { geofenceId?: string; fromMs?: number; toMs?: number; limit?: number; offset?: number };
type GeofenceEventsWindow = { fromMs?: number; toMs?: number }; // both bounds inclusive
/** Android only. Branch on `status`, not `valid`. */
type LicenseInfo = {
status: LicenseStatus; valid: boolean; packageName: string;
checkedAt: string; // ISO-8601, server clock
ttlSeconds: number; reason: string | null; fromCache: boolean;
};
type BatteryThresholdCrossing = { percent: number; threshold: number; crossing: 'below' | 'above' };
/** Android only — the argument of a registerHeadlessTask() handler. */
type HeadlessEvent = { name: TrackerEventType; params: TrackerEvent };
type SyncParamValue = string | number | boolean | null | SyncParamValue[] | { [k: string]: SyncParamValue };
type SyncConfig = {
url: string; method?: string; headers?: Record<string, string>; autoSync?: boolean; batchSize?: number;
extraParams?: Record<string, SyncParamValue>; // merged into the top level of the request body
ios?: { requiresNetworkConnectivity?: boolean; wipeOnAuthExpiry?: boolean; stopTrackingOnAuthExpiry?: boolean;
backoffInitialSec?: number; backoffCeilingSec?: number; autoSyncCoalesceSec?: number };
android?: { requiresUnmeteredNetwork?: boolean; // the two network gates are NOT the same field
syncLogs?: boolean; // default TRUE — configure() derives the log channel
gzipRequestBody?: boolean; // default false
allowCleartext?: boolean; // default false
timeouts?: { connectMs?: number; readMs?: number; writeMs?: number } };
}; // defaults: see the SyncConfig fields table in Sync
type SyncResult =
| { kind: 'uploaded'; count: number } | { kind: 'empty' } | { kind: 'retry'; reason: string }
| { kind: 'authExpired' } | { kind: 'forbidden' }; // forbidden: Android only (HTTP 403)
type SyncEvent =
| { type: 'httpResponse'; statusCode: number | null; count: number } // both platforms
| { type: 'uploaded'; count: number } // iOS only
| { type: 'retryScheduled'; afterSec: number; reason: string } // iOS only
| { type: 'authExpired' }; // iOS only
// ── Session logs — Android only ──
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogType = 'event' | 'decision' | 'message' | 'lifecycle';
type LifecyclePhase = 'session_start' | 'session_stop' | 'session_interrupted' | 'service_start'
| 'service_stop' | 'process_start' | 'boot_completed' | 'config_changed'
| 'device_motion' | 'device_location'; // the last two are written by the SDK itself
type LogRecord = {
id: string; sessionId: string | null; seq: number; timeMs: number;
elapsedRealtimeNanos: string; // string: exceeds JS number precision
level: LogLevel; type: LogType; tag: string; code: string | null; message: string;
data: string | null; // raw JSON string — parse in try/catch
};
type LogSyncResult =
| { kind: 'shipped'; count: number } | { kind: 'empty' }
| { kind: 'retry'; reason: string; retryAfterMs?: number }
| { kind: 'rejected'; statusCode: number }; // terminal for this channel; buffer kept
Enums (string unions): MotionState 'stopped'|'moving'|'stopPending'|'stationary' ·
ActivityType 'inVehicle'|'onBicycle'|'onFoot'|'walking'|'running'|'still'|'tilting'|'unknown' ·
PermissionTier 'none'|'foreground'|'always' · AccuracyAuthorization 'approximate'|'precise' ·
MotionAuthorization 'notDetermined'|'denied'|'restricted'|'authorized' (iOS) ·
TrackingMode 'continuous'|'adaptive'|'motionOnly' · MockPolicy 'flag'|'reject'|'allow' ·
DesiredAccuracy 'high'|'balanced'|'low' · AccuracyProfile 'strict'|'balanced'|'relaxed'|'custom' ·
LocationProviderType 'fused'|'gpsOnly'|'networkOnly'|'passive' (Android) ·
MotionQuality 'full'|'degraded'|'poor' · MovementStatus 'steady'|'moving' ·
SegmentType 'travel'|'stop'|'gap' (gap iOS only) · Smoothing 'none'|'spline'|'bezier' ·
CameraFollowMode 'none'|'follow'|'followBearing' ·
PowerSource 'none'|'ac'|'usb'|'wireless'|'dock'|'unknown' (iOS never reports ac/usb/wireless) ·
GeofenceTransition 'enter'|'exit'|'dwell' (dwell iOS only) ·
LicenseStatus 'active'|'revoked'|'expired'|'unknownKey'|'invalidKey'|'packageMismatch'|'sdkMismatch'|'unrecognised' (Android) ·
WakeResult 'alive'|'revived'|'refused'|'noSession'|'disabled'|'dispatched'|'timedOut'|'locationOff' (Android; dispatched is never returned through this package).
TrackerConfig
Passed to ready(). Shared fields are flat; platform-only fields live in the ios / android
namespaces. Every field is optional — omit it to keep the SDK default.
Field types follow the naming: a unit suffix (…Ms, …Sec, …Min, …M, …Meters, …Deg,
…Days, …Rows, …Capacity, …Steps, …G) is a number in that unit; use…, persist…,
enable…, show…, stopOn…, startOn…, disable…, wait… and on/off names (adaptiveCadence,
turnBurst, navigationMode, activityRecognition, cornerAnchorCapture, foregroundService,
backgroundLocationIndicator, aggressiveOemProfile, speedAdaptiveCadence) are boolean;
notification…, …Event, …Id, license, baseUrl are string. Exceptions: …ConfidenceMin
fields are number confidence minimums (not minutes), and maxRecords is a number. Enum-typed fields:
trackingMode: TrackingMode, desiredAccuracy / ios.stationaryAccuracy: DesiredAccuracy,
accuracy.profile: AccuracyProfile, mockLocationPolicy: MockPolicy (default 'flag'),
android.providerType: LocationProviderType.
| Group | Fields |
|---|---|
| Top level | license, reset (default true: the config passed to ready() persists; false → an existing persisted config wins in full) |
| Geolocation | trackingMode, desiredAccuracy, accuracy: { profile, maxAccuracyMeters, recoveryTrustMeters }, intervalMs, vehicularIntervalMs (must be <= intervalMs while adaptiveCadence is on — iOS ready() refuses an inverted ladder with invalidConfig), adaptiveCadence, turnBurst, turnBurstIntervalMs, navigationMode, navigationIntervalMs, oneShotTimeoutMs, mockLocationPolicy, deliveryStalenessMs (a diagnostic, never a gate — and read by nothing at all before the 1.0.10 Android pin) |
| Motion | activityRecognition, activityConfidenceMin (unnormalized: 66 iOS / 75 Android by design), snapshotConfidenceMin, stopTimeoutMin, stationaryRadiusM, motionTriggerDelayMs, heartbeatIntervalSec, persistHeartbeat, bearingChangeCaptureDeg (30 on both since the pinned SDKs; was 40), cornerAnchorCapture, stopOnStationary, disableStopDetection, activityRecognitionIntervalMs (a real battery control on Android; a delivery throttle that saves nothing on iOS) |
| Sensors | useStepCorroboration, useAccelerometerVeto, useBarometer, useSignificantMotion (unnormalized: a hardware sensor on Android, the pedometer with an accelerometer fallback on iOS), useGyroTurnPrediction |
| Persistence | maxDaysToPersist, persistRawFixes, rawFixRingCapacity, persistRawPoints, rawPointRingCapacity, persistDecisions, decisionRetentionDays, decisionMaxRows |
| Service | healthLoopMs, backstopIntervalMin, deadTrackerMovingMin, deadTrackerStationaryMin |
ios | backgroundLocationIndicator, stillConfidenceMin, useSignificantLocationChange, useStationaryFence, stationaryAccuracy, speedAdaptiveCadence, targetSpacingM, minIntervalMs, maxIntervalMs, significantMotionSteps, significantMotionAccelG, significantMotionAccelSustainMs, stationaryGeofenceId (must carry the reserved tracker-stationary prefix), stationaryGeofenceOnExitEvent |
android | baseUrl (scheme + host; a relative SyncConfig.url resolves against it — must be absolute), providerType, fastestIntervalMs, maxUpdateDelayMs, maxFixAgeMs, navigationFastestIntervalMs, distanceFilterM, waitForAccurateLocation, aggressiveOemProfile, maxRecords, stepBatchLatencyMs, stationaryGeofenceId, stationaryGeofenceOnEnterEvent, stationaryGeofenceOnExitEvent, foregroundService, stopOnTerminate, enableHeadless, startOnBoot, watchdogIntervalMs, watchdogThrottleMs, wakeLockMs, serviceHeartbeatMin, powerReportIntervalMin, notificationTitle, notificationText, notificationChannelId, notificationChannelName, notificationSmallIconResName, showSyncStatusInNotification, syncNotificationSubText, syncNotificationText |
Three android fields are new at the 1.0.10 pin: waitForAccurateLocation and
aggressiveOemProfile trade battery for latency on hardware whose background tracking is late
rather than absent, and serviceHeartbeatMin is an AlarmManager service-revival heartbeat that
keeps working on a MIUI/HyperOS app left at Restricted, where the WorkManager paths do not run
at all.
Documented defaults: reset true · cornerAnchorCapture true · useSignificantMotion true ·
useGyroTurnPrediction true · bearingChangeCaptureDeg 30 · mockLocationPolicy 'flag' ·
deliveryStalenessMs 0 disables · ios.speedAdaptiveCadence false · ios.targetSpacingM 25 ·
ios.minIntervalMs / ios.maxIntervalMs 1000 / 60000 · android.waitForAccurateLocation true ·
android.stationaryGeofenceId 'fieldtrack-stationary' · android.serviceHeartbeatMin 15
(0 disables) · android.showSyncStatusInNotification false · android.syncNotificationText
'unsynced {pending} · last upload {age}'. Anything not listed keeps the native SDK's default.
Per-field docs (units, validation, platform notes) ship with the package as TypeScript docblocks in
node_modules/@fieldtrack360/react-native-tracker/src/types/config.ts — your editor shows them on
hover.
End-to-end example
A single screen covering the whole journey: ready → permissions → start → live map → stop. Assumes installation and native configuration from the sections above.
// TrackingScreen.tsx
import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Platform, StyleSheet, Text, View } from 'react-native';
import Tracker, {
LiveTrackMapView,
TrackMapView,
onLiveTrack,
onStateChange,
onTrackerEvent,
type LiveTrackUpdate,
type Track,
type TrackerState,
} from '@fieldtrack360/react-native-tracker';
export function TrackingScreen() {
const [state, setState] = useState<TrackerState | undefined>();
const [live, setLive] = useState<LiveTrackUpdate | undefined>();
const [track, setTrack] = useState<Track | undefined>();
const [error, setError] = useState<string | undefined>();
// 1. Initialize once, and keep the state snapshot fresh.
useEffect(() => {
let cancelled = false;
(async () => {
const res = await Tracker.ready({
// license: '…' — required for release builds; debug/simulator builds are waived.
trackingMode: 'adaptive',
intervalMs: 1000,
activityRecognition: true,
maxDaysToPersist: 30,
});
if (cancelled) return;
if (!res.ok) setError(`${res.code}: ${res.message}`);
else setState(res.value);
})();
const unsubState = onStateChange(setState);
const unsubEvents = onTrackerEvent((event) => {
if (event.type === 'error') setError(`${event.code}: ${event.message}`);
// 'sessionInterrupted', 'providerChange', 'motionChange', … handled here too.
});
const unsubLive = onLiveTrack(setLive);
return () => {
cancelled = true;
unsubState();
unsubEvents();
unsubLive();
};
}, []);
// 2. Permission ladder — foreground, rationale, background, activity, notifications.
const ensurePermissions = useCallback(async (): Promise<boolean> => {
const tier = await Tracker.permissions.requestForeground();
if (tier === 'none') {
if (await Tracker.permissions.shouldStopAsking(2)) {
Alert.alert('Location needed', 'Enable location for this app in Settings.', [
{ text: 'Open Settings', onPress: () => { void Tracker.permissions.openAppSettings(); } },
{ text: 'Cancel', style: 'cancel' },
]);
}
return false;
}
// Your own rationale screen belongs here, before the background ask.
const bg = await Tracker.permissions.requestBackground();
if (bg.kind === 'needsSettings') {
Alert.alert('Background location', 'Choose "Always" for this app in Settings to keep recording in the background.');
}
if (Platform.OS === 'ios') {
await Tracker.ios.requestMotion();
} else {
await Tracker.android.requestActivityRecognition();
await Tracker.android.requestNotification();
}
return true;
}, []);
// 3. Start a session.
const start = useCallback(async () => {
setError(undefined);
setTrack(undefined);
if (!(await ensurePermissions())) return;
const res = await Tracker.start(`trip-${Date.now()}`);
if (!res.ok) setError(`${res.code}: ${res.message}`);
}, [ensurePermissions]);
// 4. Stop, then plot what was captured.
const stop = useCallback(async () => {
const sessionId = state?.currentSessionId;
const res = await Tracker.stop();
if (!res.ok) {
setError(`${res.code}: ${res.message}`);
return;
}
const id = res.value?.id ?? sessionId;
if (!id) return;
try {
setTrack(await Tracker.buildTrack({ sessionId: id, limit: 5000 }, { snapToRoad: true }));
} catch (e) {
setError(String(e));
}
}, [state?.currentSessionId]);
return (
<View style={styles.root}>
<Text>
{state?.isReady ? 'Ready' : 'Not ready'} · {state?.isTracking ? 'Tracking' : 'Idle'} · {state?.motionState}
</Text>
{error ? <Text style={styles.error}>{error}</Text> : null}
{state?.isTracking ? (
<LiveTrackMapView update={live} followMode="followBearing" style={styles.map} />
) : track ? (
<TrackMapView track={track} style={styles.map} />
) : (
<View style={styles.map} />
)}
<Button title="Start" onPress={start} disabled={!state?.isReady || state.isTracking} />
<Button title="Stop" onPress={stop} disabled={!state?.isTracking} />
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, padding: 16, gap: 12 },
map: { flex: 1, borderRadius: 8, overflow: 'hidden' },
error: { color: '#b00020' },
});
Add uploads by calling TrackerSync.configure({...}) once after ready() and
TrackerSync.requestSync() at your own checkpoints.
Running the bundled sample
npm install # installs the workspace and fetches + verifies the iOS frameworks
npm run example:ios # or: npm run example:android
example/ wires every bridged method into a screen — Home · Track · Fences · Debug · Decisions,
plus Sync. Its iOS bundle id is com.fieldtrack360.tracker.rnsample (Xcode/simulator builds are
licence-waived; Android debug builds are too). For Android, put TRACKER_LICENSE plus
MAPS_API_KEY (the latter only if you want the Track tab's map to draw) in
example/android/local.properties. No JitPack credentials are needed.
Troubleshooting
Installation
A postinstall script (scripts/fetch-ios-frameworks.js) fetches the pinned iOS XCFrameworks
into the package's ios/Frameworks/ and verifies a SHA-256 per framework against the digests
pinned in package.json → tracker.ios.checksums. A mismatch fails loudly and prints both
digests — the upstream iOS tag has been force-published in place more than once, so a tag-only
pin is not safe on its own. It runs automatically on npm install; you only invoke it by hand
when it did not run.
Fetch-script environment variables (all optional):
| Variable | Effect |
|---|---|
TRACKER_IOS_DIST_LOCAL | Path to a local SDK checkout (a directory containing Artifacts/) to copy from instead of downloading |
TRACKER_IOS_FORCE_DOWNLOAD=1 | Skip local/sibling checkouts and always download the pinned tag |
TRACKER_IOS_DIST_REPO | Override the source repo (default fieldtrack360/tracker-ios) |
| Symptom | Cause / fix |
|---|---|
[tracker:fetch-ios] ERROR: checksum mismatch | The upstream iOS tag was republished. Do not bypass — verify the change, then re-record digests with node scripts/fetch-ios-frameworks.js --record and commit the diff |
ios/Frameworks is empty after install | The postinstall did not run (--ignore-scripts, or a CI cache restored node_modules without it). Run node node_modules/@fieldtrack360/react-native-tracker/scripts/fetch-ios-frameworks.js |
pod install cannot find the vendored frameworks | Same cause — the fetch must run before pod install |
Android
| Symptom | Cause / fix |
|---|---|
Could not find com.github.fieldtrack360:fieldtrack:… | No JitPack repository is in the resolution set — normally React Native's root plugin injects one. If your host removed it, or pins repositoriesMode to FAIL_ON_PROJECT_REPOS without it, add maven { url 'https://jitpack.io' } to dependencyResolutionManagement.repositories in android/settings.gradle |
A stale authToken / JitPack credentials block causes a 401 | The SDK is now readable anonymously — delete the credentials from settings.gradle, the allprojects { repositories.withType(MavenArtifactRepository) … } block from android/build.gradle, and authToken from ~/.gradle/gradle.properties |
… was compiled with an incompatible version of Kotlin | Your kotlinVersion is below 2.0, or you are on an older release of this package. Upgrade the package first — a kotlinVersion override is almost never the right fix |
Dependency … requires compileSdkVersion 37 or later | An older release of this package. Upgrade it rather than installing SDK platform 37 |
Could not find …:gradle: with an empty version | The second includeBuild of @react-native/gradle-plugin in settings.gradle is missing |
| Map surface is blank, only a logcat line | The com.google.android.geo.API_KEY <meta-data> element is missing, or the manifest placeholder is empty — required once you mount a map component. Assigning manifestPlaceholders = [...] (instead of index assignment) also wipes React Native's own placeholders |
| Headless task never fires: "No task registered for key TrackerHeadless" | registerHeadlessTask() is not at the top level of index.js. A headless boot evaluates the bundle root and nothing else |
| Headless task never fires, no error | One of the three requirements is missing: android.enableHeadless: true, android.stopOnTerminate: false, and TrackerLaunch.ready(this) in MainApplication.onCreate |
| The tracking notification is a solid white square | notificationSmallIconResName points at the launcher icon (or any opaque image). Android alpha-masks a small icon — use a white-on-transparent silhouette, see The Android notification |
The notification still says "Tracking active" after setting notificationTitle | Either the icon/title config never reached the SDK — reset: false on ready() keeps the persisted config, so pass reset: true — or the app is on a plugin release older than 1.0.4, whose pinned Android SDK ignored these keys |
| Notification text is right but the icon is the generic pin | The name in notificationSmallIconResName did not resolve. It is a filename in android/app/src/main/res/drawable/ with no path and no extension; logcat names the one it could not find |
| Session stops when the app is swiped away | android.stopOnTerminate — and check the notification permission: a suppressed foreground-service notification makes the OS more willing to kill the app |
| Gap of several minutes after a swipe from recents on MIUI/ColorOS/Funtouch/OxygenOS | Those ROMs kill the process after the swipe. From 1.0.18 (Android SDK 1.0.10-alpha09) the SDK arms a restore ~5 s later and logs the diagnostic "task removed from recents during an active session; fast restore armed". It is only that fast with an exact alarm — declare SCHEDULE_EXACT_ALARM in your own manifest (on Android 14+ the user must also allow Alarms & reminders in app settings). A ROM that treats the swipe as a force stop cancels the alarm too; have the user lock the app in recents |
Crash: ForegroundServiceDidNotStartInTimeException — "Context.startForegroundService() did not then call Service.startForeground()" | The process started cold to answer the service start and ran out of the ~10 s deadline before the service was constructed. Take WorkManager's initializer off the cold-start path — Android setup step 3 — and keep other work out of Application attach/onCreate |
IllegalStateException: WorkManager is not initialized properly… in logcat, or a library's background work silently not running | The WorkManager block from Android setup step 3 is in the manifest, but Application does not implement Configuration.Provider, and a library other than the SDK (e.g. Notifee) reached WorkManager first. Add the provider, or remove the block |
| Tracking stops an hour into a session on an OEM build, no error | Battery policy. Tracker.android.getBackgroundRestrictions() — degraded is true when the app is background-restricted or in the rare/restricted standby bucket. Offer requestBatteryOptimizationExemption() / openBatteryOptimizationSettings(), see Battery optimisation |
| Long straight lines on the map, with nothing recorded in between | The process was frozen, killed or in Doze. Look in the session log for ProcessExit (o-kill, PACKAGE_UPDATED) and gaps between DEVICE_LOCATION re-inits. Exempt the app from battery optimisation and enable the OEM auto-launch toggle; wire remote wake; draw time gaps as breaks on your map, not as travel |
unknown_geofence:fieldtrack-stationary, then minutes with no points while driving | Fixed from 1.0.17 (Android SDK 1.0.10-alpha08): the stationary wake fence could stay registered with Play Services after the SDK lost its record of it, and the exit was dropped. Upgrade the package |
Server sends idle pushes but last_seen never moves | Nothing calls Tracker.android.wake(), the message has a notification block, or it is not high priority. Send data-only, high-priority messages, see Remote wake (FCM) |
remote wake: NO_SESSION in the log | The push arrived, but no session was open. Expected after end of day or sign-out — stop pushing to that device |
remote wake: REFUSED in the log | The platform refused the foreground-service start — usually a normal-priority message, or an OEM rule. Confirm "priority": "high"; the SDK retries on its own restore path |
playServicesUnavailable | The device has no usable Google Play services; the fused provider is unavailable |
| Background points arrive in clumps a minute late, then catch up | OS batching, not a dead provider — the default android.maxUpdateDelayMs lets the OS hold a fix for a full interval. Set android.aggressiveOemProfile: true (Android SDK 1.0.10) for the whole latency trade, or android.maxUpdateDelayMs: 0 for that half alone. Set deliveryStalenessMs to have lateness reported as a diagnostic event so you can tell this apart from a provider that has stopped |
| Nothing recovers the service on MIUI/HyperOS at the Restricted battery setting | JobScheduler does not run there at all, which takes out backstopIntervalMin and the restore worker together. android.serviceHeartbeatMin (Android SDK 1.0.10, default 15) is an AlarmManager chain on a separate subsystem. Declaring SCHEDULE_EXACT_ALARM in your own manifest upgrades it to an exact alarm, which on API 31+ is also what makes it eligible to start the foreground service — the SDK will not declare that permission on your behalf |
NoClassDefFoundError: Failed resolution of: Lokhttp3/internal/Util; on the first cookie-bearing request | A split OkHttp family. The SDK links OkHttp 5, which deleted that internal class, and Gradle's conflict resolution raises the okhttp module alone — React Native's okhttp-urlconnection 4.x stays behind and its JavaNetCookieJar calls into the removed class. Fixed by the pinned Android SDK from 1.0.9, which constrains okhttp-urlconnection to the core jar's version. If your own build forces OkHttp versions, align both artifacts yourself. A -dontwarn okhttp3.** keep rule is the wrong fix — it turns the build-time error into this crash |
iOS
| Symptom | Cause / fix |
|---|---|
| Crash in the launch window naming a background-task identifier | BGTaskSchedulerPermittedIdentifiers is missing or does not match com.fieldtrack360.tracker.backstop / com.fieldtrack360.tracker.sync verbatim |
ready() resolves backgroundTasksNotRegistered | The BGTaskScheduler launch handlers were never installed — either an identifier is missing from BGTaskSchedulerPermittedIdentifiers, or the registration ran after launch. Tracking is unaffected; you lose the advisory 15-minute backstop. TrackerLaunch.ready() does this for you, so the fix is to call it as the first statement of didFinishLaunchingWithOptions |
ready() fails outright, but only in debug | The same cause as the row above. A DEBUG build refuses rather than degrading, deliberately, so a missing launch call cannot ship. Release builds never fail for it |
| No background capture at all, no error | TrackerLaunch.ready() is not called from didFinishLaunchingWithOptions, or is called after React Native starts |
error: expected '{' in enum inside Tracker*.swiftinterface | Xcode is older than the compiler that built the pinned iOS SDK (Swift 6.4). Update to Xcode 27.1+ |
The iOS Simulator deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 9.0/12.4, but the range of supported deployment target versions is 15.0 to 27.1.x on a third-party pod (e.g. RNSVG-RNSVGFilters, RNDeviceInfo-RNDeviceInfoPrivacyInfo) | Not Tracker — Xcode 27 rejects pods below iOS 15.0. In post_install, raise every pod target below 15.0 to 15.0 (the sample's Podfile carries the snippet), then pod install |
dyld: … MinimumOSVersion load failure at launch | The app target's IPHONEOS_DEPLOYMENT_TARGET is below 17.0. The Podfile post_install gate catches this at install time — add it |
| Crash on first permission prompt | A missing NSLocation…UsageDescription / NSMotionUsageDescription string |
getCurrentLocation fails with oneShotBusy / oneShotCircuitOpen | iOS, and not retryable. oneShotBusy means a capture is already in flight — await that one. oneShotCircuitOpen means three consecutive failures opened the circuit; it closes when authorization is granted, location services return, a session starts, or ready() succeeds — and after 60 s one trial attempt is let through (a failed trial reopens it for another 60 s) |
getCurrentLocation adds points to the track | iOS only, and by design — the SDK's one-shot feeds the ingestor by default. Android never does this |
Licence and configuration
| Symptom | Cause / fix |
|---|---|
licenseMissing in a release build, fine in debug | iOS only — Android no longer checks the token locally. Supply the token via Tracker.ready({ license }), the only route |
licenseBundleMismatch | iOS only. The token was issued for a different bundle id (including .dev / .staging variants). Each app id needs its own token or an explicitly licensed alias |
| Android release build runs with no licence at all | Expected since Android 1.0.1: the offline gate was removed, so only the online revocation check enforces anything. Do not read a successful ready() on Android as "licensed" |
licenseInvalid | Truncated or wrapped token, or one from a different key generation |
licenseInvalid, or "wrong prefix" in the log | Prefix mismatch. Both current guides document TRACKIT- tokens; confirm with your vendor if yours differs |
invalidConfig rejection from ready() | The config object failed to decode natively — usually an out-of-range value or a field in the wrong namespace |
| Config changes have no effect | reset: false was passed, so the persisted config won. Pass reset: true (the default) on a later launch |
deviceIntegrityBlocked (Android, release only) | The integrity layer refused the device/build; the in-flight session ends |
Permissions
| Symptom | Cause / fix |
|---|---|
| The background prompt never appears | It was requested before foreground was granted (needsForegroundFirst), or iOS already spent its one Always escalation. Only Settings can recover it |
requestBackground() resolves needsSettings immediately | Permanently denied. Explain what to tap, then openAppSettings() |
| Points stop when the app is backgrounded | Foreground-only authorization — capture degrades rather than failing. Watch for backgroundPermissionMissing |
| Every fix is rejected as too coarse | coarseOnly — approximate accuracy. On iOS, ask via Tracker.ios.requestTemporaryFullAccuracy(purposeKey) |
Runtime
| Symptom | Cause / fix |
|---|---|
Everything returns notReady | ready() was never awaited, or it resolved { ok:false } and the result was swallowed |
| A subscription never fires | Either it is a platform-only event (geofenceDwell, licenseDeactivated, trackingGap on iOS; integrityChange, licenseChecked, powerReport on Android), a SyncEvent type Android never emits (everything but httpResponse), or the unsubscribe ran early — verify the effect's cleanup |
| Geofence crossings are missing after a relaunch | Crossings delivered to a relaunched process never reach a live JS subscriber. Read Tracker.geofences.getEvents() at launch |
geofenceLimitReached | The usable cap is 19 |
buildTrack result looks short | The query page came back full — check track.warnings for the truncation notice and raise limit |
| Snapped track falls back to raw geometry | snapUnavailable — no provider set, or the OSRM endpoint failed |
Map does not respond to gestures inside a ScrollView | The scroll container is intercepting touches; isolate the map's touch handling |
Known limitations
- Android needs a Google Maps API key to render the map components — nothing else in the SDK needs one, and nothing else about the Android build needs host configuration.
- Expo Go is not supported — use a development build / prebuild.
License
MIT for this bridge. The Tracker SDK itself is proprietary and requires a licence token for release builds.
Try it before you buy
A 30-day trial licence for one application, issued instantly. Development builds are licence-waived, so you can evaluate the whole SDK first.
Get a trial keyVerification API
The SDK handles licensing for you. This is documented for tooling.
POST https://tracksdkhub.com/api/v1/verify