Field Track 360

Developer guide

Integrate the SDK

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

Published from the 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+ (.nvmrc pins v24)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.

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. Tracker.shared.ready() 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() 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.

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, and the two platforms enforce it differently as of Android 1.0.1 — the Android SDK removed its offline signature gate, iOS kept its own. Read the enforcement table below before you build a licence-gating screen on top of ready().

Getting a licence

Buy one at https://fieldtrack360-sdk.devstree.in/, which also has a free trial for evaluating first. 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)noready() 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 validvalid 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? }
  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. ForegroundrequestForeground(). 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. BackgroundrequestBackground(). 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 / motionTracker.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
Permission revoked in Settings while runningAndroid restarts the process; the session resumes with whatever tier is left. 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 sessionstart() 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));

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

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
trackTrackyesThe object returned by Tracker.buildTrack(). Serialised to the native view; the renderer never recomputes geometry
optionsobjectnoRenderer 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) => voidnoThe 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
updateLiveTrackUpdatenoThe 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 }noCamera centre before the first frame arrives
optionsobjectnoLive renderer styling. Platform-divergent (iOS followDistanceMeters/followPitchDegrees vs Android followZoom/followTilt); no lossless mapping, so it is not unified
onFollowingChange(isFollowing: boolean) => voidnoiOS 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.
});

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 });
await Tracker.geofences.deleteEvents('depot');

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}` },
  autoSync: true,
  batchSize: 200,
  ios: { requiresNetworkConnectivity: true, backoffInitialSec: 5, backoffCeilingSec: 300 },
  android: { requiresUnmeteredNetwork: false },   // NOT the same policy as the iOS gate
});

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

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: Android's SDK SyncEvent has the single httpResponse case, so uploaded / retryScheduled / authExpired never fire there — read upload outcomes from syncNow() / pendingCount() instead. 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.


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 Android only
deleteEvents(geofenceId?)string?Promise<number>Omit the id to delete all

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
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, or a failed Android SyncConfig.validate() (cleartext url, verb outside POST/PUT/PATCH, batchSize out of range)
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

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 (32 values):

  • Shared (19): notReady, permissionDenied, backgroundPermissionMissing, coarseOnly, locationDisabled, fgsStartRefused, fixTimeout, storageFull, storageReset, trackerDead, invalidConfig, motionDetectionDegraded, snapUnavailable, internalError, licenseMissing, licenseInvalid, licenseBundleMismatch, licenseRevoked, licenseExpired
  • iOS only (3): oneShotBusy, oneShotCircuitOpen, fixRejected — all three from getCurrentLocation(), and none of them worth retrying
  • Android only (10): playServicesUnavailable, notificationHidden, noActivity, geofenceRegistrationFailed, geofenceRemovalFailed, geofenceLimitReached, deviceIntegrityBlocked, licenseUnknown, licensePackageMismatch, licenseSdkMismatch

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

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

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
  android?: { onEnterEvent?: string; onExitEvent?: string };
};

type GeofenceCrossing = {
  geofenceId: string; transition: 'enter' | 'exit' | 'dwell';
  timeMs?; latitude?; longitude?; radiusM?: number;   // absent on Android live events
};

type SyncConfig = {
  url: string; method?: string; headers?: Record<string, string>; autoSync?: boolean; batchSize?: number;
  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
};

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, adaptiveCadence, turnBurst, turnBurstIntervalMs, navigationMode, navigationIntervalMs, oneShotTimeoutMs, mockLocationPolicy, deliveryStalenessMs
MotionactivityRecognition, activityConfidenceMin (unnormalized: 66 iOS / 75 Android by design), snapshotConfidenceMin, stopTimeoutMin, stationaryRadiusM, motionTriggerDelayMs, heartbeatIntervalSec, persistHeartbeat, bearingChangeCaptureDeg, stopOnStationary, disableStopDetection, activityRecognitionIntervalMs (a real battery control on Android; a delivery throttle that saves nothing on iOS)
SensorsuseStepCorroboration, useAccelerometerVeto, useBarometer
PersistencemaxDaysToPersist, persistRawFixes, rawFixRingCapacity, persistRawPoints, rawPointRingCapacity, persistDecisions, decisionRetentionDays, decisionMaxRows
ServicehealthLoopMs, backstopIntervalMin, deadTrackerMovingMin, deadTrackerStationaryMin
iosbackgroundLocationIndicator, stillConfidenceMin, useSignificantLocationChange, useStationaryFence
androidproviderType, fastestIntervalMs, maxUpdateDelayMs, maxFixAgeMs, navigationFastestIntervalMs, distanceFilterM, maxRecords, useSignificantMotion, stepBatchLatencyMs, stationaryGeofenceId, stationaryGeofenceOnEnterEvent, stationaryGeofenceOnExitEvent, foregroundService, stopOnTerminate, startOnBoot, watchdogIntervalMs, watchdogThrottleMs, wakeLockMs, notificationTitle, notificationText, notificationChannelId, notificationChannelName, notificationSmallIconResName

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

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

Security

The Android SDK needs no build-time credential — the AARs resolve anonymously from JitPack, so there is no repository token to store or rotate. Licence tokens are bundle-bound and verified offline, but they still belong in gitignored properties rather than committed source (the AAR ships a FieldTrackLicenseHardcoded lint warning for exactly this). The iOS frameworks are pinned by SHA-256 and re-verified on every install.

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