React Native developer guide · FieldTrack360
FieldTrack360

Developer guide

Integrate the SDK

Native SDKs for Android and iOS, with bridges for React Native and Flutter. Pick your platform - the setup genuinely differs, so these are not one page.

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

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

Package@fieldtrack360/react-native-tracker
PlatformsiOS 17+, Android API 26+
ArchitectureNew Architecture only (TurboModule + Fabric codegen)

Requirements

MinimumNotes
React Native0.81+ with New Architecture enabledDeveloped and verified against RN 0.87
Node22+Needed for the iOS framework fetch on install
iOS17.0The vendored XCFrameworks target arm64-apple-ios17.0 and are not weak-linked
Xcode / CocoaPodsXcode 26+, CocoaPods 1.15+
AndroidminSdk 26, compileSdk 36, target 36The config plugin raises minSdkVersion/compileSdkVersion for you; it never lowers them
Kotlin2.0+ (verified: 2.1.20)React Native's own pin is enough — no override needed
AGP8.x+ (verified: 8.11)
JDK17 (11+ works)
MapsA Google Maps API key — optional, and only on AndroidNeeded 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.

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

@main
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 optionTypeEffect
androidMapsApiKeystringcom.google.android.geo.API_KEY manifest meta-data — needed only for the map components. Omitted → no meta-data written.
locationWhenInUsestringOverrides NSLocationWhenInUseUsageDescription.
locationAlwaysstringOverrides NSLocationAlwaysAndWhenInUseUsageDescription.
motionUsagestringOverrides 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.fieldtrack360.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 license field.
  • 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 fails ready() with licenseBundleMismatch.

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

iOSAndroid
Token checked offline at ready()yes — signature + bundle bindingno, 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 / expiredstops 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 Licence status on Android 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.

Licence status on Android

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.


Initialization and ready state

Initialization is two-phase:

PhaseWhereWhat
A — launch hookNative. 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 readyawait 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.

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.reset defaults to true: config passed to ready() is persisted and used by later sessions. With reset: false an existing persisted config wins in full and the object you pass is ignored. There is no live setConfig().
  • 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

Not requried to add permissions in your manifest file. it's merged from itself:

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

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:

  1. Foreground — requestForeground(). Fine + coarse in one request. Resolves the settled PermissionTier.
  2. Your own rationale UI — a screen explaining why background access is needed. Required in practice, not by the API.
  3. Background — requestBackground(). iOS shows the Always escalation at most once, and only from When-In-Use; Android cannot request ACCESS_BACKGROUND_LOCATION in 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.
  4. Activity / motion — Tracker.ios.requestMotion() on iOS (Motion & Fitness), Tracker.android.requestActivityRecognition() on Android ("Physical activity"). One rung, two OS names, same capability.
  5. 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

SituationBehaviour
Foreground deniedgetTier() is 'none'; start() resolves { ok:false, code:'permissionDenied' }
Background denied / downgraded mid-sessionCapture degrades to foreground-only rather than stopping; a TrackerEvent error with backgroundPermissionMissing is emitted
Reduced (approximate) accuracygetAccuracy() 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-widelocationDisabled; 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 runningAndroid 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 againshouldStopAsking(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);

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_LOCK while a task runs and releases it when the service's tasks drain. Every event type is delivered, location included — 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.

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

FieldTypeEffect
trackingMode'continuous' | 'adaptive' | 'motionOnly'Cadence policy
desiredAccuracy'high' | 'balanced' | 'low'Provider accuracy request
intervalMs / vehicularIntervalMsnumberBase and in-vehicle cadence
accuracy.profile'strict' | 'balanced' | 'relaxed' | 'custom'Fix-acceptance strictness
activityRecognitionbooleanMotion classification on/off
stopTimeoutMin, stationaryRadiusMnumberStop detection
maxDaysToPersistnumberRetention
persistRawFixes / persistRawPoints / persistDecisionsbooleanGate 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 notificationChannelId creates 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. channelName is 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 sessionInterrupted event carrying the session.
  • stop() on no open session resolves { ok: true, value: null }-shaped or an error result depending on platform; check ok and treat a null value as "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

PropTypeRequiredDefaultNotes
trackTrackyes—The object returned by Tracker.buildTrack(). Serialised to the native view; the renderer never recomputes geometry
optionsobjectno—Renderer styling. Platform-divergent and intentionally unmerged — an iOS RenderOptions-shaped object on iOS, an Android RendererOptions-shaped object on Android. Passed through as-is
onArrowZoom(zoom: number) => voidno—The renderer needs direction arrows rebuilt at a new zoom. Rebuild with buildTrack(query, { ...options, zoom }) and pass the new track — do not rescale
…ViewPropsstyle, testID, etc. children is not supported
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

PropTypeRequiredDefaultNotes
updateLiveTrackUpdateno—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'nonative defaultCamera behaviour
initialCentre{ latitude: number; longitude: number }no—Camera centre before the first frame arrives
optionsobjectno—Live renderer styling. Platform-divergent (iOS followDistanceMeters/followPitchDegrees vs Android followZoom/followTilt); no lossless mapping, so it is not unified
onFollowingChange(isFollowing: boolean) => voidno—iOS only — fires when the user pans away from the puck. Android has no follow-state callback and never emits it
…ViewPropschildren is not supported
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).

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

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

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.

  • location is reserved. It is the batch itself, and both SDKs refuse the key.
  • null is the one value that does not mean the same thing on both platforms. iOS models it and encodes JSON null; the Android SDK has no null value — "omit the key instead" — so a null-valued key is dropped there. A null inside an array would renumber the array if dropped, so Android rejects that as invalidConfig rather 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 points url plus v1/logs/batch, inheriting device_id from extraParams and 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 with android: { syncLogs: false } on the points config. Deriving the channel is never fatal to points: if it cannot be built (no device_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().

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.

types defaults 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 first configureLogs() 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. A start() refused at the permission gate opens no session, so this one is filed against the device with a null sessionId. It is 'warn' whenever the state would stop or degrade tracking, so it survives the default level: 'info'. A granted start writes one line per session; a refused start writes every time.
  • '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

Every method below is on the default export (Tracker). All return Promises. Unless stated, availability is both platforms.

Lifecycle

MethodParametersReturnsNotes
ready(config?)config?: TrackerConfigPromise<TrackerResult<TrackerState>>Phase B init. Rejects invalidConfig on undecodable config. iOS background-task fields are fixed at launch
start(tag?)tag?: stringPromise<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

MethodParametersReturnsNotes
getPoints(query?)query?: PointQueryPromise<TrackPoint[]>Default page size 500 on both platforms
getCount(query?)query?: PointQueryPromise<number>
getOdometerMeters()—Promise<number>
getSessions(fromMs?, toMs?)fromMs?: number, toMs?: numberPromise<TrackSession[]>Both bounds optional
currentSession()—Promise<TrackSession | null>

Current location

MethodParametersReturnsNotes
getCurrentLocation()—Promise<TrackerResult<TrackFix>>iOS also stores the fix on the open session; Android reports it only. iOS failures name themselves — fixTimeout is retryable, oneShotBusy / oneShotCircuitOpen / fixRejected are not

Plotting

MethodParametersReturnsNotes
buildTrack(query?, options?)PointQuery, TrackOptionsPromise<Track>Decoded track tree. Read track.precision (default 6) when decoding polylines; check track.warnings
exportPolylineJson(query?, options?)samePromise<string>SDK polyline JSON
exportGeoJson(query?, options?)samePromise<string>GeoJSON; coordinate order [lon, lat]

Road snapping / live surface

MethodParametersReturnsNotes
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

MethodParametersReturnsNotes
getRawFixes(sessionId)stringPromise<RawFix[]>Gated by config.persistRawFixes
getRawPoints(sessionId)stringPromise<RawPoint[]>Gated by config.persistRawPoints
getDecisions(sessionId?, limit?, offset?)string, number, numberPromise<FixDecision[]>Gated by config.persistDecisions
offerFix(fix)TrackFixPromise<void>Injects a fix; does not bypass validation
getSensors()—Promise<DeviceSensors>
getBatteryInfo()—Promise<BatteryInfo>One-shot; onBatteryChange for a live value

Tracker.permissions

MethodParametersReturnsNotes
getTier()—Promise<PermissionTier>'none' | 'foreground' | 'always'
getAccuracy()—Promise<'approximate' | 'precise'>
shouldStopAsking(attempts)attempts: numberPromise<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

MethodParametersReturnsNotes
add(fence)GeofencePromise<TrackerResult<Geofence>>dwellAfterMs, or an explicit notifyOnEntry/Exit: false, → invalidConfig on Android
list()—Promise<Geofence[]>
get(id)stringPromise<Geofence | null>
remove(id)stringPromise<TrackerResult<boolean>>
removeAll()—Promise<TrackerResult<number>>
getEvents(opts?)GeofenceEventsQueryPromise<GeofenceCrossing[]>Source of truth. fromMs/toMs honoured on both
deleteEvents(geofenceId?, window?)string?, GeofenceEventsWindowPromise<number>Omit both to delete all. window is { fromMs?, toMs? }, honoured on both

Tracker.ios — rejects unsupportedOnPlatform on Android

MethodParametersReturns
changePace(isMoving)booleanPromise<TrackerResult<void>>
requestMotion()—Promise<MotionAuthorization>
getMotionAuthorization()—Promise<MotionAuthorization>
requestTemporaryFullAccuracy(purposeKey)stringPromise<'approximate' | 'precise'>

Tracker.android — rejects unsupportedOnPlatform on iOS

MethodParametersReturns
hasActivityRecognition()—Promise<boolean>
requestActivityRecognition()—Promise<boolean>
hasNotificationPermission()—Promise<boolean>
requestNotification()—Promise<boolean> (no-op below Android 13)

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.

FunctionCallbackNotes
onTrackerEvent(cb)(event: TrackerEvent) => voidThe 19-case union below
onLiveTrack(cb)(update: LiveTrackUpdate) => voidFeed straight into <LiveTrackMapView>
onPoints(sessionId, cb)(points: TrackPoint[]) => voidStored points for one session
onStateChange(cb)(state: TrackerState) => void
onProviderStateChange(cb)(state: ProviderState) => void
onBatteryChange(cb)(battery: BatteryInfo) => voidBoth platforms
registerHeadlessTask(task)(event: { name, params }) => 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 }
  | { 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 }          // radiusM is the CLAMPED value
  | { type: 'geofenceRemoved';    geofenceId: string }
  | { type: 'batteryChange';      battery: BatteryInfo }
  | { type: 'licenseDeactivated'; status: string; reason: string | null }  // iOS only
  | { type: 'trackingGap';        durationSec: number; distanceMeters: number };  // iOS only

A case that is absent on a platform simply never arrives — do not build a liveness assumption on one.

TrackerSync

MethodParametersReturnsNotes
configure(config)SyncConfigPromise<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>uploaded / empty / retry / authExpired, plus forbidden (Android only)
pendingCount()—Promise<TrackerResult<number>>
onSyncEvent(cb)(e: SyncEvent) => void() => voidBoth platforms; only httpResponse is emitted on Android
ios.onSyncEvent(cb)(e: SyncEvent) => void() => voidDeprecated 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.

MethodParametersReturnsNotes
android.configureLogs(config?)LogSyncConfigPromise<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, stringPromise<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>shipped / empty / retry / rejected — 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, endpoint? }>
android.onLogEvent(cb)(e: SyncEvent) => void() => voidThis 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 from getCurrentLocation(), and none of them worth retrying — plus backgroundTasksNotRegistered
  • 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;
};

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

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

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.

GroupFields
Top levellicense, reset (default true: the config passed to ready() persists; false → an existing persisted config wins in full)
GeolocationtrackingMode, 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)
MotionactivityRecognition, 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)
SensorsuseStepCorroboration, useAccelerometerVeto, useBarometer, useSignificantMotion (unnormalized: a hardware sensor on Android, the pedometer with an accelerometer fallback on iOS), useGyroTurnPrediction
PersistencemaxDaysToPersist, persistRawFixes, rawFixRingCapacity, persistRawPoints, rawPointRingCapacity, persistDecisions, decisionRetentionDays, decisionMaxRows
ServicehealthLoopMs, backstopIntervalMin, deadTrackerMovingMin, deadTrackerStationaryMin
iosbackgroundLocationIndicator, stillConfidenceMin, useSignificantLocationChange, useStationaryFence, stationaryAccuracy, speedAdaptiveCadence, targetSpacingM, minIntervalMs, maxIntervalMs, significantMotionSteps, significantMotionAccelG, significantMotionAccelSustainMs, stationaryGeofenceId (must carry the reserved tracker-stationary prefix), stationaryGeofenceOnExitEvent
androidproviderType, fastestIntervalMs, maxUpdateDelayMs, maxFixAgeMs, navigationFastestIntervalMs, distanceFilterM, waitForAccurateLocation, aggressiveOemProfile, maxRecords, stepBatchLatencyMs, stationaryGeofenceId, stationaryGeofenceOnEnterEvent, stationaryGeofenceOnExitEvent, foregroundService, stopOnTerminate, enableHeadless, startOnBoot, watchdogIntervalMs, watchdogThrottleMs, wakeLockMs, serviceHeartbeatMin, 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.

Per-field docblocks live in src/types/config.ts.


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

VariableEffect
TRACKER_IOS_DIST_LOCALPath to a local SDK checkout (a directory containing Artifacts/) to copy from instead of downloading
TRACKER_IOS_FORCE_DOWNLOAD=1Skip local/sibling checkouts and always download the pinned tag
TRACKER_IOS_DIST_REPOOverride the source repo (default fieldtrack360/tracker-ios)
SymptomCause / fix
[tracker:fetch-ios] ERROR: checksum mismatchThe 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 installThe 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 frameworksSame cause — the fetch must run before pod install

Android

SymptomCause / 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 401The 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 KotlinYour 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 laterAn older release of this package. Upgrade it rather than installing SDK platform 37
Could not find …:gradle: with an empty versionThe second includeBuild of @react-native/gradle-plugin in settings.gradle is missing
Map surface is blank, only a logcat lineThe 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 errorOne 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 squarenotificationSmallIconResName 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 notificationTitleEither 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 pinThe 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 awayandroid.stopOnTerminate — and check the notification permission: a suppressed foreground-service notification makes the OS more willing to kill the app
playServicesUnavailableThe device has no usable Google Play services; the fused provider is unavailable
Background points arrive in clumps a minute late, then catch upOS 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 settingJobScheduler 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 requestA 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

SymptomCause / fix
Crash in the launch window naming a background-task identifierBGTaskSchedulerPermittedIdentifiers is missing or does not match com.fieldtrack360.tracker.backstop / com.fieldtrack360.tracker.sync verbatim
ready() resolves backgroundTasksNotRegisteredThe 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 debugThe 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 errorTrackerLaunch.ready() is not called from didFinishLaunchingWithOptions, or is called after React Native starts
dyld: … MinimumOSVersion load failure at launchThe 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 promptA missing NSLocation…UsageDescription / NSMotionUsageDescription string
getCurrentLocation fails with oneShotBusy / oneShotCircuitOpeniOS, 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, or a session starts
getCurrentLocation adds points to the trackiOS only, and by design — the SDK's one-shot feeds the ingestor by default. Android never does this

Licence and configuration

SymptomCause / fix
licenseMissing in a release build, fine in debugiOS only — Android no longer checks the token locally. Supply the token via Tracker.ready({ license }), the only route
licenseBundleMismatchiOS 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 allExpected 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"
licenseInvalidTruncated or wrapped token, or one from a different key generation
licenseInvalid, or "wrong prefix" in the logPrefix 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 effectreset: 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

SymptomCause / fix
The background prompt never appearsIt was requested before foreground was granted (needsForegroundFirst), or iOS already spent its one Always escalation. Only Settings can recover it
requestBackground() resolves needsSettings immediatelyPermanently denied. Explain what to tap, then openAppSettings()
Points stop when the app is backgroundedForeground-only authorization — capture degrades rather than failing. Watch for backgroundPermissionMissing
Every fix is rejected as too coarsecoarseOnly — approximate accuracy. On iOS, ask via Tracker.ios.requestTemporaryFullAccuracy(purposeKey)

Runtime

SymptomCause / fix
Everything returns notReadyready() was never awaited, or it resolved { ok:false } and the result was swallowed
A subscription never firesEither it is a platform-only stream (geofenceDwell, geofenceAdded/Removed), 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 relaunchCrossings delivered to a relaunched process never reach a live JS subscriber. Read Tracker.geofences.getEvents() at launch
geofenceLimitReachedThe usable cap is 19
buildTrack result looks shortThe query page came back full — check track.warnings for the truncation notice and raise limit
Snapped track falls back to raw geometrysnapUnavailable — no provider set, or the OSRM endpoint failed
Map does not respond to gestures inside a ScrollViewThe 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 key

Verification API

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

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