@bricks-soft/capacitor-location-tracking
v8.0.1
Published
Background location tracking for Capacitor with GMS/HMS fused location, geofencing, HTTP sync and heartbeat audit records.
Downloads
437
Readme
@bricks-soft/capacitor-location-tracking
Background location tracking for Capacitor 8 on Android. It records locations with Google Play services (GMS),
Huawei Location Kit (HMS) or the plain Android LocationManager, stores them in SQLite and uploads them to your
server. While tracking is on, it also sends heartbeat audit records every 3–5 minutes, so your server can tell
whether the user really kept tracking on.
Clean-room implementation. This plugin offers a feature set similar to transistorsoft/capacitor-background-geolocation, with a new, smaller API. It was written from scratch: it contains no code from that project (whose native engine is closed-source and commercial) and is not affiliated with, endorsed by or supported by Transistor Software. Moving an app from its v8 release: docs/migrating-from-transistor-v8.md.
Contents
- Features
- Platform status
- Installation
- Android setup
- Quick start
- Configuration reference
- Tracking behavior
- Battery
- Geofencing
- Records, uploads and the queue (including
live location with
http.syncInterval) - Events reference
- Companion plugins (native API)
- Error codes
- Web stub
- Server side
- Example app
- Field-force example
- End-to-end tests
- Publishing to npm
- API
Features
- Motion-aware tracking. The plugin switches between moving (continuous, high-accuracy updates with an elastic
distance filter) and stationary (GPS off; a stationary geofence, passive fixes and activity recognition watch for
movement). It reports
motionchangeandactivitychangeevents. See Battery. - Three location backends. It supports GMS fused location, HMS fused location and the Android
LocationManager. You choose which SDKs go into the APK at build time (locationTracking.providers) and which one to use at runtime (locationProvider: 'auto' | 'gms' | 'hms' | 'android'). - Heartbeat audit. If no record has been created for
heartbeat.minIntervalseconds (default 180), the plugin creates aheartbeatrecord with the last known location. It should exist beforemaxInterval(default 300), and it is uploaded immediately. Itsheartbeatobject tells the server when the next one is due. See docs/heartbeat.md. - Audit records.
tracking_start,tracking_stop(with a reason) andproviderchangerecords are uploaded the same way, so the server can explain gaps. - Offline-first HTTP sync. Every record is stored in SQLite first. Uploads can send one record or a batch per
request, use JSON templates, merge
paramsinto the body, and refresh a JWT on401. Failed uploads stay queued and are retried, andrecorded_at/sent_atexpose late delivery.http.syncIntervalbatches location uploads while keeping the server's live location at most that many seconds old. See docs/wire-format.md. - Companion native API. Another plugin in the same app (Kotlin) receives every record and every event natively, also when no WebView runs (after a reboot or an alarm), and can call the plugin. See Companion plugins.
- Geofencing. Up to 100 circular or polygon geofences, with
ENTER,EXITandDWELLtransitions, and a geofences-only tracking mode (startGeofences()). See Geofencing. - One-off and watched positions.
getCurrentPosition()(best of N samples) andwatchPosition(). - Odometer with an accuracy gate, plus location filters: accuracy threshold, implied-speed filter, optional Kalman smoothing and mock-location handling.
- Foreground service with a configurable notification and up to three action buttons.
- Restart after reboot, app update and process death (
startOnBoot,stopOnTerminate: false). - Device helpers. Permission handling (including the Android 10/11+ background-location flow), battery-optimization status and settings, phone-maker power-manager screens, provider state, device info and sensors.
- Persistent log. A file log with levels and retention, which you can query, upload or email.
Platform status
| Platform | Status |
|---|---|
| Android (API 24+, Kotlin) | ✔ Supported |
| iOS | Planned for a later phase. Until then every call rejects with "not implemented on ios". |
| Web | Stub for development only: positions through navigator.geolocation, an in-memory config, permissions and device info. Nothing is stored or uploaded, and tracking can't be started. Everything else rejects with UNIMPLEMENTED. See Web stub. |
Installation
npm i @bricks-soft/capacitor-location-tracking
npx cap sync androidRequirements: Capacitor 8, Android minSdk 24, compileSdk / targetSdk 36 and JDK 21. The plugin is built with
AGP 8.13 and Kotlin 2.2.
Android setup
1. Choose the location SDKs to package
The plugin compiles against both SDKs. The Gradle property locationTracking.providers decides which of them are
packaged into your APK. Set it in your app's android/gradle.properties:
# gms (default) | hms | gms,hms
locationTracking.providers=gms,hms| Value | Packages | Typical use |
|---|---|---|
| gms (default) | com.google.android.gms:play-services-location | Google Play builds |
| hms | com.huawei.hms:location | AppGallery-only builds |
| gms,hms | both | One APK for phones with Google services and Huawei phones without them |
You can also pass the property on the command line (./gradlew assembleRelease -PlocationTracking.providers=hms).
The property applies to the whole Gradle run, so every product flavor of the app gets the same SDKs. To build a Play
APK and an AppGallery APK from product flavors, use none and add the SDKs per flavor, as described in
Separate Play and AppGallery APKs (product flavors).
The SDK versions default to play-services-location 21.3.0 and com.huawei.hms:location 6.16.0.302. To use other
versions, set playServicesLocationVersion or hmsLocationVersion in the ext block of your app's
android/variables.gradle. If you package hms for Google Play, keep 6.16.0.302: it is Huawei's first release with
16 KB page-size support, and the latest one in Huawei's version history. Earlier versions bring native libraries with
4 KB alignment, which fail Play's 16 KB page-size requirement; so do the undocumented 6.17–6.19 builds in Huawei's
Maven repository.
At runtime, the locationProvider config option picks the backend. GMS or HMS counts as packaged when all three
of these are true:
The app allows it. By default both are allowed. An app restricts them with a
<meta-data>entry in its<application>, for example per product flavor:<meta-data android:name="com.brickssoft.locationtracking.PROVIDERS" android:value="hms"/>The value is a comma-separated list of
gms,hmsandandroid(case and spaces don't matter).androidalone means the plugin uses neither GMS nor HMS. Unknown names are ignored with a warning in the plugin log; a value with no known name is ignored with an error, and both providers stay allowed.Its SDK classes are in the APK.
The app's merged manifest declares the plugin's two receivers for it (
provider.gms.GmsActivityReceiverandGmsGeofenceReceiver, orprovider.hms.HmsActivityReceiverandHmsGeofenceReceiver). Activity and geofence results arrive through those receivers, so a backend without them cannot work.
A provider that the <meta-data> entry lists but that fails 2 or 3 is a setup error: the plugin logs a warning and
does not use it. The locationProvider values:
'auto'(default): GMS if it is packaged and Google Play services are available; otherwise HMS if it is packaged and HMS Core is available; otherwise the AndroidLocationManager.'gms'or'hms': that backend. If it is not packaged or not available on the phone, the plugin logs a warning and uses the AndroidLocationManager.'android': always the AndroidLocationManager.
The backend is chosen once per process and again whenever locationProvider changes (while tracking, the location
request, activity updates and geofences move to the new backend). getState().backend and getDeviceInfo()
(gmsAvailable, hmsAvailable, packagedProviders) show what was selected.
The android backend has two limitations: it has no activity recognition, so motion detection relies on distance
only, and no native geofence dwell, so the plugin synthesizes DWELL itself (see Geofencing).
Release builds (R8 / minify). Nothing to add: the plugin ships consumer ProGuard/R8 rules that keep its provider bundles and the GMS/HMS class names it probes by reflection, so a minified release app still detects Google Play services and HMS. They also include Huawei's recommended keep rules and silence warnings about the SDK that is not packaged.
Separate Play and AppGallery APKs (product flavors)
An app that publishes a Google Play APK with only GMS and an AppGallery APK with only HMS adds the SDKs per product
flavor itself, instead of through locationTracking.providers. The plugin chooses the backend by checking at runtime
which SDK classes are in the APK and which of its receivers the manifest declares, so it needs no other setting.
In
android/gradle.properties, stop the plugin from packaging any SDK:locationTracking.providers=noneIn
android/app/build.gradle, declare the flavors and add one SDK to each:android { flavorDimensions = ['store'] productFlavors { gms { dimension 'store' } hms { dimension 'store' } } } dependencies { gmsImplementation 'com.google.android.gms:play-services-location:21.3.0' hmsImplementation 'com.huawei.hms:location:6.16.0.302' }The app now sets the SDK versions;
playServicesLocationVersionandhmsLocationVersionno longer apply.Add the Huawei Maven repository (step 2). For AppGallery Connect (step 3), put
agconnect-services.jsoninandroid/app/src/hms/instead ofandroid/app/.In each flavor's manifest, declare the provider and remove the other provider's entries. The plugin's manifest declares the receivers and the Android 9 activity-recognition permissions of both SDKs, and Android merges it into every flavor. These entries do nothing in an APK without that SDK, but they show in the merged manifest and in the store listing's permission list. Two flavor manifests remove them.
Each flavor manifest also declares which provider the plugin may use, with the
com.brickssoft.locationtracking.PROVIDERS<meta-data>entry. The plugin never selects a provider that is not listed, also not withlocationProvider: 'gms'or'hms'(it falls back toandroidwith a warning). So the AppGallery APK uses HMS even when another library brings Google's location classes into it (for example another location plugin, or the app's own Google Play services availability check) and the phone has Google Play services installed. The app does not need to setlocationProvider.android/app/src/gms/AndroidManifest.xml(Google Play APK: allows GMS, removes the HMS entries):<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="com.huawei.hms.permission.ACTIVITY_RECOGNITION" tools:node="remove"/> <application> <meta-data android:name="com.brickssoft.locationtracking.PROVIDERS" android:value="gms"/> <receiver android:name="com.brickssoft.locationtracking.provider.hms.HmsActivityReceiver" tools:node="remove"/> <receiver android:name="com.brickssoft.locationtracking.provider.hms.HmsGeofenceReceiver" tools:node="remove"/> </application> </manifest>android/app/src/hms/AndroidManifest.xml(AppGallery APK: allows HMS, removes the GMS entries):<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="com.google.android.gms.permission.ACTIVITY_RECOGNITION" tools:node="remove"/> <application> <meta-data android:name="com.brickssoft.locationtracking.PROVIDERS" android:value="hms"/> <receiver android:name="com.brickssoft.locationtracking.provider.gms.GmsActivityReceiver" tools:node="remove"/> <receiver android:name="com.brickssoft.locationtracking.provider.gms.GmsGeofenceReceiver" tools:node="remove"/> </application> </manifest>Keep everything else from the plugin's manifest in both flavors. The
<queries>entries such ascom.huawei.systemmanagerare phone makers' battery settings apps, not HMS, and the plugin uses them on every phone (step 6).Build both APKs:
./gradlew assembleGmsRelease assembleHmsRelease(orbundleGmsReleasefor Play).
What each APK then contains:
| | Google Play APK (gms) | AppGallery APK (hms) |
|---|---|---|
| Location SDK and its dependencies | play-services-location only | com.huawei.hms:location only |
| Manifest entries that the SDK adds itself | Google's only | Huawei's only |
| Plugin receivers and activity-recognition permission | GMS only (after step 4) | HMS only (after step 4) |
| Plugin classes in provider.gms and provider.hms | both | both |
| getDeviceInfo().packagedProviders | ["gms"] | ["hms"] |
The plugin classes of both providers stay in both APKs, because the R8 rules keep every provider bundle for the
runtime check. They are in the com.brickssoft.locationtracking package and contain no Google or Huawei code.
Other libraries in the app can still bring an SDK into the wrong flavor; for example, Firebase depends on Google Play services. Check each flavor before publishing:
./gradlew :app:dependencies --configuration hmsReleaseRuntimeClasspathlists the libraries in the AppGallery APK (gmsReleaseRuntimeClasspathfor the Play APK).android/app/build/intermediates/merged_manifests/hmsRelease/(or Android Studio's Merged Manifest tab) shows the final manifest.- On a phone,
getDeviceInfo().packagedProvidersshows which providers the installed APK can use.
2. Huawei Maven repository (needed for hms, and harmless otherwise)
The HMS SDK is served from Huawei's own Maven repository. Add it to both repository blocks of your app's
android/build.gradle:
buildscript {
repositories {
google()
mavenCentral()
maven { url = 'https://developer.huawei.com/repo/' }
}
// ...
}
allprojects {
repositories {
google()
mavenCentral()
maven { url = 'https://developer.huawei.com/repo/' }
}
}3. AppGallery Connect (only for HMS)
HMS Location Kit works only for an app that is registered in AppGallery Connect (AGC) with the right signing certificate:
- In AppGallery Connect, create a project and
add an Android app whose package name matches your
applicationId. - Under Project settings → General information, add the SHA-256 certificate fingerprint of every signing key
you use (debug and release, and the app-signing key if AppGallery re-signs your app). You can print the fingerprints
with
./gradlew signingReportorkeytool -list -v -keystore <keystore>. - Make sure Location Kit is enabled under Project settings → Manage APIs.
- Download
agconnect-services.jsonand put it inandroid/app/. - Apply the AGC Gradle plugin. In
android/build.gradle, addclasspath 'com.huawei.agconnect:agcp:1.9.6.300'(or the latest version from the Huawei repository) tobuildscript.dependencies. Then, inandroid/app/build.gradle, addapply plugin: 'com.huawei.agconnect'afterapply plugin: 'com.android.application'.
The phone needs HMS Core (the "HMS Core" / "Huawei Mobile Services" app). If AGC is not set up correctly,
Location Kit calls fail with errors such as 907135xxx or 6003. Check the plugin log (getLog()) for them.
4. Permissions (merged automatically)
The plugin's manifest is merged into your app, so you don't have to declare anything. It declares:
| Permission | Why |
|---|---|
| INTERNET | Uploading records, heartbeats and logs. |
| ACCESS_NETWORK_STATE | Detecting connectivity: retry when the network returns, disableAutoSyncOnCellular, the connectivitychange event. |
| ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION | Foreground ("while in use") location. Runtime permission, alias location. |
| ACCESS_BACKGROUND_LOCATION | "Allow all the time" (Android 10+). Runtime permission, alias backgroundLocation. Needed for geofencing and for restarting tracking from the background (boot, app update, process restore). |
| ACTIVITY_RECOGNITION | Motion activity (still, walking, in vehicle, ...) on Android 10+. Runtime permission, alias activityRecognition. |
| com.google.android.gms.permission.ACTIVITY_RECOGNITION (maxSdk 28) | GMS activity recognition on Android 9 and older. |
| com.huawei.hms.permission.ACTIVITY_RECOGNITION (maxSdk 28) | HMS activity identification on Android 9 and older. Not requested at runtime by requestPermissions(): if HMS Core requires it on such a phone, request it yourself, otherwise activity updates fail (logged) and motion detection uses distance only. |
| FOREGROUND_SERVICE | Running the tracking foreground service. |
| FOREGROUND_SERVICE_LOCATION | The location foreground-service type (required on Android 14+). |
| POST_NOTIFICATIONS | Showing the foreground-service notification on Android 13+. Runtime permission, alias notifications. |
| RECEIVE_BOOT_COMPLETED | Resuming tracking after a reboot or an app update (app.startOnBoot). |
| WAKE_LOCK | Keeping the CPU awake briefly while a heartbeat is created and uploaded. |
These are intentionally not declared:
| Permission | Why not |
|---|---|
| SCHEDULE_EXACT_ALARM | Denied by default on Android 14+ for new installs, and it needs a policy justification. Apps exempt from battery optimization may use exact alarms without it. |
| USE_EXACT_ALARM | Google Play allows it only for alarm-clock and calendar apps. |
| REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | Google Play restricts the direct "allow this app to ignore battery optimizations?" prompt. The plugin opens the system list instead (openBatteryOptimizationSettings()), where the user exempts the app. |
The manifest also adds the tracking service (foregroundServiceType="location", stopWithTask="false"), the boot,
alarm, notification-action, geofence and activity receivers, and a FileProvider for sharing log files (authority
${applicationId}.locationtracking.logs). It also adds <queries> entries, so that the phone makers'
power-manager apps and mailto: handlers are visible on Android 11+.
If you don't want a permission, remove it in your app's manifest and turn off the matching feature. For example:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- no activity recognition: also set activity.disableMotionActivityUpdates = true -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" tools:node="remove"/>
</manifest>5. Runtime permission flow
requestPermissions() asks, by default, for all four permissions in this order: location, notifications,
activityRecognition, then backgroundLocation. Pass { permissions: [...] } to ask for only some of them. It asks
for one permission at a time, skips those that are already granted or don't exist on the phone's Android version, and
resolves with the resulting PermissionStatus. Overlapping calls run one after another.
- Status values. A permission that is not granted is
'prompt'if the plugin never asked for it (in this install),'prompt-with-rationale'if Android recommends explaining it first, and'denied'otherwise (the user refused, and Android won't show the dialog again). Coarse-only location counts aslocation: 'granted'. start()needs only foreground location. The foreground service keeps "while in use" access while it runs. Callstart()while your app is visible, because Android 12+ restricts starting foreground services from the background.- Android 10 (API 29). The system dialog offers "Allow all the time" directly.
- Android 11+ (API 30+). Background location is requested only after foreground location has been granted (if
it isn't, the background step is skipped), and the "request" opens the app's location-permission page in Settings,
where the user picks Allow all the time. The plugin first shows an explanation dialog, which you can reword with
backgroundPermissionRationale; if the user declines it, or no Activity is visible, the step is skipped. Google Play requires your own in-app disclosure before this, plus a declaration in the Play Console. - Android 12+. The user may grant only approximate location.
getProviderState().accuracythen reports'approximate'. - Android 13+.
POST_NOTIFICATIONSis a runtime permission. If it is denied, tracking still works, but the foreground-service notification is hidden. - Below Android 10, there is no separate background permission (it is reported the same as
location), and activity recognition needs no runtime prompt (it is reported as'granted'). Notifications below Android 13 are also reported as'granted'. - A permission whose Android permission you removed from the app's manifest (
tools:node="remove") is not requested. - Background location is needed for geofencing and for resuming tracking after a reboot, an app update or process
death. On Android 14+ a resume from the background without it ends with a
tracking_stopwhose reason isservice_start_failed. - Revoking a location permission in Settings kills the app's process. While tracking is on, the plugin records a
providerchangethe next time it runs; if location permission is gone completely, it also stops tracking with atracking_stopwhose reason ispermission_denied.
For apps targeting Android 14+, Google Play also asks you to declare the location foreground-service type in the
Play Console.
6. Battery optimization and phone makers
For reliable heartbeats on idle phones, ask the user to exempt the app from battery optimization, and on some phones to allow it in the phone maker's power manager. See docs/heartbeat.md for the details and the expected intervals.
const status = await LocationTracking.getBatteryOptimizationStatus();
if (!status.isIgnoringBatteryOptimizations) {
// Explain why first; the user picks the app and chooses "Don't optimize" / "Unrestricted".
await LocationTracking.openBatteryOptimizationSettings();
}
const pm = await LocationTracking.getPowerManagerInfo();
if (pm.available) {
// e.g. Huawei: Battery → App launch → your app → Manage manually (all switches on)
await LocationTracking.openPowerManagerSettings();
}Quick start
import { LocationTracking, onHeartbeat, onLocation } from '@bricks-soft/capacitor-location-tracking';
// 1. Listen first, so no event is missed.
await onLocation((location) => console.log('[location]', location.event, location.coords));
await onHeartbeat(({ location }) => console.log('[heartbeat]', location.recorded_at, location.coords));
await LocationTracking.addListener('providerchange', (state) => console.log('[providerchange]', state));
await LocationTracking.addListener('http', (e) => console.log('[http]', e.status, e.uuids.length));
// 2. Call ready() on every app launch, before any other method.
const state = await LocationTracking.ready({
config: {
locationProvider: 'auto',
geolocation: { desiredAccuracy: 'high', distanceFilter: 10 },
heartbeat: { enabled: true, minInterval: 180, maxInterval: 300 },
http: {
url: 'https://api.example.com/locations',
headers: { 'X-Api-Key': 'my-key' },
params: { device_id: 'device-123' },
},
app: { stopOnTerminate: false, startOnBoot: true },
notification: { title: 'Trip recording', text: 'Your location is being shared' },
},
});
// 3. Ask for foreground permissions, then start while the app is visible.
const permissions = await LocationTracking.requestPermissions({
permissions: ['location', 'notifications', 'activityRecognition'],
});
if (permissions.location === 'granted' && !state.enabled) {
await LocationTracking.start();
}
// 4. Later, after explaining why, ask for "Allow all the time".
await LocationTracking.requestPermissions({ permissions: ['backgroundLocation'] });
// Check the heartbeat at any time:
const hb = await LocationTracking.getHeartbeatStatus();
console.log(hb.strategy, hb.nextHeartbeatAt, hb.isIgnoringBatteryOptimizations);The NOT_READY rule. Until ready() has resolved in the current process (once per process, not per WebView),
every method rejects with NOT_READY, except ready, getState, checkPermissions, requestPermissions,
getDeviceInfo, getSensors, log, getLog, getProviderState and the open*Settings methods
(openBatteryOptimizationSettings, openPowerManagerSettings, openLocationSettings, openAppSettings).
An app that calls getCurrentPosition() (or watchPosition()) for users who never configure tracking calls
ready({ reset: false }) without a config first; that is the intended pattern. It loads the persisted config (the
defaults on the first run) and changes nothing, and calling ready() again later in the process is fine. Two side
effects:
- If tracking is enabled but not running in this process, this
ready()resumes it (reasonrestore), with the persisted config. - It counts as the first
ready()after install, so a laterready({ config, reset: false })keeps the persisted config and ignores the one passed. Usereset: trueorsetConfig()to apply a config after that.
ready({ reset }). With reset: true (the default), the config is the defaults plus the config you pass, on every
launch. With reset: false, the config you pass is applied only on the very first ready() after install, and
afterwards the persisted config wins (change it with setConfig()). Note that with reset: true, JWT tokens the
plugin refreshed are replaced by the ones in the config you pass (see
JWT refresh). If tracking was on but is not running in this process (for example
after the app was force-stopped or its process was killed), ready() resumes it and records a tracking_start with
reason restore.
Force stop. After the user force-stops the app (Settings → Force stop), Android cancels its alarms and does not
restart its service, so tracking stays off until the app is opened again; ready() then resumes it. On Android 11
and newer the plugin also ignores a background event that reaches the stopped app anyway (an activity update or a
stationary-region exit that was already on its way): it does not restart tracking from it, and it releases the leftover
activity, location and geofence registrations so they stop waking the app. The server sees a gap without a
tracking_stop record, followed by tracking_start with reason restore when the app is opened.
setConfig() deep-merges: arrays and map-like objects (headers, params, extras, ...) are replaced as a
whole, and null resets a key or a group to its default. Unknown keys are ignored with a warning in the log. Enum
values are case-insensitive, and numeric or boolean strings ("15", "false") are accepted. A wrong type or an
unknown enum value rejects the call with INVALID_ARGUMENT, and nothing is applied. Out-of-range values are clamped
(see the Configuration reference). reset({ config }) starts from the defaults
instead.
Event helpers. Besides LocationTracking.addListener(name, cb), the package exports typed helpers (onLocation,
onMotionChange, onActivityChange, onProviderChange, onHeartbeat, onGeofence, onGeofencesChange, onHttp,
onConnectivityChange, onPowerSaveChange, onEnabledChange, onNotificationAction, onAuthorization), a generic
on(name, cb) and an Events map of event names. Each returns a PluginListenerHandle. Call handle.remove() or
LocationTracking.removeAllListeners() to unsubscribe.
Configuration reference
All options are optional. getState().config returns the full config with every default filled in (including the
JWT tokens). Out-of-range values are clamped to the limits given below, and every clamp is logged as a warning;
numbers that are not finite become the default.
config (top level)
| Option | Type | Default | Description |
|---|---|---|---|
| locationProvider | 'auto' \| 'gms' \| 'hms' \| 'android' | 'auto' | Location backend (Android only). See Android setup. |
config.geolocation
| Option | Type | Default | Description |
|---|---|---|---|
| desiredAccuracy | 'high' \| 'balanced' \| 'low' \| 'passive' | 'high' | Accuracy of the location request while moving, and of the first fix after start(). While stationary the plugin requests only passive fixes (or, when the stationary geofence cannot be registered, one low-power fix at most every 3 minutes), whatever this value is (see Battery). |
| distanceFilter | number (m, ≥ 0) | 10 | Minimum distance between recorded locations while moving. The plugin applies it itself (the OS request has no distance filter). |
| locationUpdateInterval | number (ms, ≥ 0) | 1000 | Desired update interval while moving. |
| fastestLocationUpdateInterval | number (ms, ≥ 0) | 500 | Fastest accepted update interval. |
| disableElasticity | boolean | false | Turns off the speed-based scaling of distanceFilter. |
| elasticityMultiplier | number (≥ 0) | 1 | Elastic filter: distanceFilter × max(1, round(speed / 5) × elasticityMultiplier). |
| stationaryRadius | number (m, ≥ 1) | 25 | While stationary: the stationary geofence has a radius of max(stationaryRadius, 150) m, and a fix whose distance from the stop point minus its accuracy is more than stationaryRadius (with an accuracy no worse than filter.trackingAccuracyThreshold) switches to moving. While moving, a displacement beyond it counts as evidence of motion for stop detection. |
| stopTimeout | number (min, ≥ 0) | 5 | Minutes without evidence of motion (a displacement beyond stationaryRadius, or a confident moving activity) before switching to stationary. Values below 1 act as 1. |
| stopAfterElapsedMinutes | number (min, ≥ 0) | 0 (off) | Stops tracking automatically this many minutes after it started (tracking_stop, reason stop_after_elapsed). While the phone sleeps, the stop can come later: see Field-force example. If the deadline passes while the phone is off or the app is not running, the stop is recorded when the plugin next runs, and tracking does not resume. |
| stopOnStationary | boolean | false | Stops tracking when stop detection switches the device to stationary (tracking_stop, reason stop_on_stationary). changePace({ isMoving: false }) does not stop tracking. |
| locationTimeout | number (ms, > 0) | 30000 | Default timeout of getCurrentPosition(), and of the first fix after start(). |
| filter | object | see below | Location filters. |
config.geolocation.filter
| Option | Type | Default | Description |
|---|---|---|---|
| useKalman | boolean | false | Smooths latitude and longitude with a Kalman filter. |
| trackingAccuracyThreshold | number (m, ≥ 0) | 100 | Rejects fixes whose accuracy is worse than this. |
| maxImpliedSpeed | number (m/s, ≥ 0) | 80 | Rejects fixes that imply a higher speed than this (GPS jumps). 0 turns the check off. |
| odometerAccuracyThreshold | number (m, ≥ 0) | 20 | The odometer ignores fixes whose accuracy is worse than this. |
| allowIdenticalLocations | boolean | false | Records fixes that are identical to the previous one. |
| rejectMockLocations | boolean | false | Drops mock fixes. Otherwise they are kept, with mock: true. |
config.activity
| Option | Type | Default | Description |
|---|---|---|---|
| disableMotionActivityUpdates | boolean | false | Doesn't use activity recognition. Motion detection then relies on distance only. |
| activityRecognitionInterval | number (ms, ≥ 0) | 10000 | Requested interval of activity updates. |
| minimumActivityRecognitionConfidence | number (0–100) | 75 | Minimum confidence for an activity to count (for motion detection and the activitychange event). |
| motionTriggerDelay | number (ms, ≥ 0) | 0 | How long a moving activity must last before switching to moving. |
| disableStopDetection | boolean | false | Never switch to stationary automatically; only changePace() does. |
config.heartbeat
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Creates heartbeat records while tracking is on. |
| minInterval | number (s, ≥ 60) | 180 | A heartbeat is due when no record has been created for this long. |
| maxInterval | number (s, ≥ minInterval) | 300 | Upper end of the heartbeat window: the heartbeat should exist by then. A heartbeat Android delivers later is still created, and the lateness is logged. |
config.http
| Option | Type | Default | Description |
|---|---|---|---|
| url | string | null | none | Upload endpoint. Without a URL (or with one that isn't a valid http(s) URL) nothing is uploaded, and records stay queued. |
| method | 'POST' \| 'PUT' \| 'PATCH' | 'POST' | HTTP method. |
| headers | object | {} | Extra request headers. They can override Content-Type; an Authorization header turns the JWT handling off. |
| params | object | {} | Merged into the root of every request body. Keys that the body already has are not overwritten. Ignored for a batch with rootProperty: '.'. |
| autoSync | boolean | true | Uploads normal records automatically. Priority records (heartbeat and audit records) are always uploaded immediately, and take queued normal records along. |
| autoSyncThreshold | number (≥ 0) | 0 | Uploads normal records when the queue holds at least this many records. With syncInterval 0, the value 0 uploads every record. With syncInterval above 0, it is a size limit: the queue is uploaded early when it reaches this many records, and 0 means no size limit. |
| syncInterval | number (s, ≥ 0) | 0 | Uploads normal records once the oldest queued one is this old, so the server's live location is at most about this stale (the field-force setup uses 300). Needs autoSync: true. 0 turns it off; priority records are not affected. See Live location. |
| batchSync | boolean | false | Sends several records per request, as an array, oldest first. |
| maxBatchSize | number (≥ 1) | 100 | Maximum records per batch request. |
| disableAutoSyncOnCellular | boolean | false | On cellular, uploads only priority records. sync() ignores it. |
| rootProperty | string | 'location' | Key that wraps the record or records. '.' (or an empty string) means no wrapping. |
| locationTemplate | string | null | null | JSON text with <%= name %> placeholders that replaces the default record shape. |
| geofenceTemplate | string | null | null | Template for geofence records. Falls back to locationTemplate. |
| timeout | number (ms, > 0) | 60000 | Request timeout (call, read and write). |
| authorization | object | null | null | JWT settings, see below. |
The upload rules (priority records, retries, templates, body shapes) are described in docs/wire-format.md.
config.http.authorization
| Option | Type | Default | Description |
|---|---|---|---|
| strategy | 'JWT' | 'JWT' | Only JWT is supported. |
| accessToken | string | none | Sent as Authorization: Bearer <accessToken>, unless headers already contain Authorization. If it is missing and refreshUrl is set, the plugin refreshes it before the first upload. |
| refreshToken | string | none | Substituted for {refreshToken} in refreshPayload. |
| refreshUrl | string | none | Token-refresh endpoint (POST). |
| refreshPayload | object | {} | Refresh request body. String values may contain {refreshToken}. |
| refreshHeaders | object | none | Headers of the refresh request. |
| refreshPayloadEncoding | 'json' \| 'form' | 'json' | Body encoding of the refresh request. |
| expires | number (epoch ms) | -1 | Access-token expiry. -1 means unknown. The token is refreshed 60 s before it expires, and after a 401 (at most one refresh per upload). Updated by every refresh. |
config.persistence
| Option | Type | Default | Description |
|---|---|---|---|
| maxDaysToPersist | number (≥ 1) | 7 | Queued records older than this many days are deleted, even if they were never uploaded. Applies to every record type, heartbeats and audit records included. |
| maxRecordsToPersist | number (≥ -1) | -1 | Maximum queued records; the oldest are deleted first. -1 (or 0) means unlimited. Pruning runs every 50 inserts, so the queue can exceed the limit by up to 49 records. |
| extras | object | {} | Merged into the extras of every record when it is created. Extras passed to a call (for example getCurrentPosition({ extras })) win over these. |
config.app
| Option | Type | Default | Description |
|---|---|---|---|
| stopOnTerminate | boolean | true | Stops tracking when the user swipes the app away (tracking_stop, reason terminate). With false, tracking continues. |
| startOnBoot | boolean | false | Resumes tracking after a reboot or an app update, if it was on (tracking_start, reason boot or package_replaced). Needs "Allow all the time" location on Android 14+. With false, tracking is off after a reboot or an update, and a tracking_stop with reason reboot or package_replaced is recorded. On Android 14+ with only "while in use" location, Android refuses it (tracking_stop reason service_start_failed); notification.resume lets the user resume with a tap. |
config.notification
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | app label | Notification title. |
| text | string | 'Location tracking is active' | Notification text. |
| smallIcon | string | 'drawable/lt_ic_notification' | 'drawable/name' or 'mipmap/name'. Must be a monochrome icon. If it can't be found (or is an adaptive launcher icon), the plugin's icon is used. |
| largeIcon | string | none | 'drawable/name' or 'mipmap/name'. |
| color | string | none | Accent color, '#RRGGBB'. |
| priority | 'min' \| 'low' \| 'default' \| 'high' \| 'max' | 'default' | Mapped to the channel importance. Android fixes a channel's importance when it is created, so use a new channelId to change it later. |
| channelId | string | 'location_tracking' | Notification channel id. |
| channelName | string | 'Location tracking' | Channel name shown in the system settings. |
| actions | { id, label }[] | none | Up to 3 buttons (extra ones are dropped). A tap emits notificationaction with the button's id. |
| resume | object | see below | The notification that resumes tracking after Android refused to restore it. |
config.notification.resume
When tracking was on and Android refuses to restore it from the background, the plugin records a tracking_stop with
reason service_start_failed, and tracking stays off until the app starts it again. This is the normal case after a
reboot, an app update or a killed process on Android 14+ when the app has only "while in use" location (no "Allow all
the time"). With resume.enabled, the plugin then posts a notification. Tapping it starts the tracking service
directly: Android lets a location foreground service started from a notification use "while in use" location. The
session resumes with the persisted config and its original stopAfterElapsedMinutes deadline, and records a
tracking_start with reason resume_notification.
- The notification has its own channel (
location_tracking_resume, default importance), so it shows even when the tracking notification'spriorityismin. It uses the tracking notification's small icon and color. - It is not posted when the session's
stopAfterElapsedMinutesdeadline has passed, and it disappears at that deadline. - It is removed when tracking starts by any path, when the app calls
stop(), and whenresume.enabledis turned off. - Without notification permission (
POST_NOTIFICATIONS, Android 13+) it is not posted; the log says so.
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | false | Post the resume notification when Android refuses to restore tracking. |
| title | string | app label | Notification title. |
| text | string | 'Location tracking is paused. Tap to resume.' | Notification text. The default comes from the string resource lt_resume_notification_text, so an app can translate it. |
| channelName | string | 'Paused location tracking' | Channel name shown in the system settings (string resource lt_resume_channel_name). |
config.geofence
| Option | Type | Default | Description |
|---|---|---|---|
| initialTriggerEntry | boolean | true | Fires ENTER right away for geofences that already contain the device when they are registered (with GMS and HMS also DWELL after loiteringDelay for dwell geofences). The android backend always reports ENTER for a circle the device is already inside, so there false only applies to polygons. |
config.logger
| Option | Type | Default | Description |
|---|---|---|---|
| logLevel | 'off' \| 'error' \| 'warn' \| 'info' \| 'debug' \| 'verbose' | 'info' | Minimum level written to the plugin log. |
| logMaxDays | number (≥ 1) | 3 | Days of log files to keep. |
config.backgroundPermissionRationale
The dialog shown before the Android 11+ "Allow all the time" settings page.
| Option | Type | Default |
|---|---|---|
| title | string | Allow background location |
| message | string | To keep tracking your location while the app is closed or not in use, select "Allow all the time" on the next screen. |
| positiveAction | string | Change to "Allow all the time" |
| negativeAction | string | Not now |
Tracking behavior
Starting and stopping.
start()needs foreground location permission (otherwise it rejects withPERMISSION_DENIED). It starts the foreground service, records atracking_start(reasonstart), emitsenabledchange, and resolves with the newState. The firstmotionchange(is_moving: false, with a fresh fix or the last known location) follows asynchronously, withinlocationTimeout+ 5 s.If Android refuses to start the foreground service right away,
start()records atracking_stopwith reasonservice_start_failedand rejects withPERMISSION_DENIED. This happens in two cases:- Android 12+ throws for a foreground-service start from the background (the app is not visible and has no exemption);
- on Android 14+, the plugin's own check predicts that Android would refuse the
locationservice type, and the plugin does not try. The check refuses only when all of these are true: the tracking service is not in the foreground yet; "Allow all the time" location is not granted; Android rates the app's process below "visible" (a process that runs another foreground service counts as visible); the app's current activity is not started; and no activity of the app was started, stopped or destroyed in the last 15 s.
If the service fails a moment later (after
start()resolved), tracking stops with reasonservice_start_failed; listen forenabledchange.startGeofences()works the same way in geofences-only mode (reasonstart_geofences). Callingstart()whilestartGeofences()runs (or the reverse) switches the mode and records anothertracking_start. Calling the same one twice does nothing.stop()removes the location and activity updates, records atracking_stop(reasonstop) while the foreground service is still running, then stops the service, the heartbeat and the OS geofences, and emitsenabledchange.Tracking also stops by itself:
stopOnStationary,stopAfterElapsedMinutes,stopOnTerminate(app swiped away), a revoked location permission (permission_denied), or Android refusing the foreground service (service_start_failed). Each case records atracking_stopwith that reason and emitsenabledchange. When Android restarts the killed service by itself and the restart fails, the reason ispermission_deniedif location permission is no longer granted, andservice_start_failedotherwise.Tracking resumes by itself after the process was killed (reason
restore), after a reboot (boot) and after an app update (package_replaced, both only withapp.startOnBoot), and whenready()finds it enabled but not running. See docs/heartbeat.md for what Android allows. When Android refuses such a restore,notification.resumecan offer a tap that resumes it (resume_notification).A boot broadcast restores tracking once per boot. The plugin reads the phone's boot counter (
Settings.Global.BOOT_COUNT) and ignores a boot broadcast when the counter is the same as for the last boot broadcast it handled, or the same as when it last started the tracking service. So a repeated or fakeQUICKBOOT_POWERONbroadcast without a real reboot creates no secondtracking_start. On a phone that does not report the counter, this check is skipped; as before, only the first boot broadcast in a process is handled.
Motion states (start() mode only).
- Moving: continuous updates with the configured
desiredAccuracyand intervals. Alocationrecord is created when a fix is at least the (elastic)distanceFilteraway from the last recorded one. - Stationary: GPS off. The plugin removes its location request and asks only for passive fixes (fixes that
other apps requested), and registers one OS geofence, the stationary region, around the stop point (the
anchor fix). The foreground service keeps running and heartbeats continue; they carry the anchor fix. Fixes that
still arrive only feed motion detection and polygon geofences; no
locationrecords are created and the odometer doesn't grow, so position jitter while parked is neither uploaded nor counted. Details in Battery. - Stationary → moving, whichever comes first: the OS reports that the device left the stationary region; a fix is
certainly outside (its distance from the anchor minus its accuracy is more than
stationaryRadius, and its accuracy is no worse thanfilter.trackingAccuracyThreshold); a moving activity (walking, running, on foot, on bicycle, in vehicle) at or aboveminimumActivityRecognitionConfidencelasts formotionTriggerDelay; orchangePace({ isMoving: true }). Then the configured request (GPS for'high') starts again and amotionchange(is_moving: true) is recorded with the fix that showed the movement (the region's exit fix, or the outside fix), or with the best known fix when the trigger was an activity orchangePace. - Moving → stationary (stop detection, unless
disableStopDetection):stopTimeoutminutes (at least 1) after the last evidence of motion, even if no more fixes arrive. - Each transition records a
motionchangewith the newis_moving.changePace({ isMoving })forces a transition; it is ignored while tracking is off or in geofences-only mode. - Without activity recognition (the
androidbackend,disableMotionActivityUpdates, or no activity permission), motion is detected from the stationary region and from distance only.
Positions. getCurrentPosition() works whether or not tracking is on. It needs location permission
(PERMISSION_DENIED) and enabled location services (LOCATION_DISABLED). It takes up to samples fixes, stops
early on a fix of 10 m or better, and resolves with the most accurate one; at the timeout it resolves with the best
fix so far, or rejects with TIMEOUT if there is none. With maximumAge, a recent enough known fix is returned
right away. With persist: true (default) the fix is stored as a current_position record, which is uploaded,
emitted as a location event and restarts the heartbeat window. watchPosition() reports each fix (or an error,
such as PERMISSION_DENIED) to its callback until clearWatch(); it doesn't report disabled location services as
an error (fixes start once they are switched on). With persist: true, each fix is also stored as a
watch_position record (uploaded, and emitted as a location event).
Battery
Tracking for 12 hours or more a day must cost little battery without losing the audit trail. The plugin follows the same idea as Transistor Software's "philosophy of operation": GPS runs only while the device moves. Heartbeats are a separate, fixed rule: they are always created and uploaded while tracking is on, and they never turn GPS on.
What runs in each state (start() mode):
| State | Runs | Does not run |
|---|---|---|
| Moving | The location request with desiredAccuracy ('high' = GPS) every locationUpdateInterval (1 s by default); activity recognition; uploads; the heartbeat schedule (heartbeats are rarely due, because records keep coming). | – |
| Stationary | The foreground service and its notification; a passive location request (it receives only fixes that other apps requested, and costs nothing by itself); the stationary region: one OS geofence of max(stationaryRadius, 150) m around the anchor fix (the stop point), with an exit trigger only; activity recognition; a heartbeat every heartbeat.minInterval seconds. | GPS, and any network-location request of the plugin (except the low-power fallback below). |
| Tracking off | Nothing. | Everything. |
How the plugin notices that the device moves again (whichever comes first):
- the OS reports that the device left the stationary region (typically after 150–250 m). An exit report whose own fix is certainly inside the region (distance from the centre plus accuracy ≤ radius) is ignored: it is a late report about an earlier region;
- a fix that arrives while stationary (a passive fix, or a fix of the low-power fallback) is certainly outside: its
distance from the anchor minus its accuracy is more than
stationaryRadius, and its accuracy is no worse thanfilter.trackingAccuracyThreshold(a coarse fix alone never wakes GPS); - activity recognition reports walking, running, on foot, on bicycle or in vehicle, with at least
minimumActivityRecognitionConfidence, formotionTriggerDelay; - the app calls
changePace({ isMoving: true }).
Then GPS starts again, and a motionchange (is_moving: true) is recorded: with the fix of the region's exit report
(case 1, when that fix passes the location filters) or the outside fix (case 2), otherwise with the best known fix.
The anchor. The anchor is the fix where the device became stationary: the fix of the motionchange with
is_moving: false (after start(), a fresh fix). While stationary, the anchor changes only in these cases:
- no fix was known when the device became stationary: the first accepted fix becomes the anchor;
- the anchor was more than 10 minutes old when it became the anchor, and a fix arrives that is at most 10 minutes
old and has an accuracy no worse than
filter.trackingAccuracyThreshold: that fix replaces the anchor; - a fix that is not certainly outside has a better (smaller) accuracy than the anchor: that fix replaces the anchor.
The stationary region is registered only around an anchor that was at most 10 minutes old when it became the anchor (the OS never reports an exit for a device that is already outside a new region). The region is moved only when the anchor is more than 50 m from the region's centre.
Low-power fallback. The plugin uses a low-power request (Wi-Fi and cell towers, no GPS; at most one fix per 3 minutes) instead of the passive one when:
- the region cannot be registered: no "Allow all the time" location, a backend error, or no answer from the OS within 10 s;
- no anchor is known, or the anchor was more than 10 minutes old when it became the anchor (see above);
- the app has 99 or 100 geofences of its own. GMS accepts at most 100 geofences per app, and the plugin gives the last slot to the app's geofences: when a geofence change brings the app to 99 geofences, the plugin removes the stationary region (see Geofencing).
The plugin logs each case. With desiredAccuracy: 'passive' the fallback request stays passive. After a failed
registration the plugin tries again when the device leaves the stationary state, when the location backend changes,
and on every location provider change while stationary (for example after "Allow all the time" is granted); it does
not try again on every fix. A polygon geofence still forces the moving request while the device is inside the
polygon's enclosing circle (see Geofencing).
Heartbeats while stationary. A heartbeat carries the anchor fix. Its recorded_at is the time the heartbeat was
created (now); timestamp (location.timestamp in JavaScript) is the time the anchor fix was acquired, which can
be hours earlier. A passive or low-power fix that arrives while stationary changes what the heartbeat carries only
when it becomes the anchor. A record with its own fix does change it: a transition of one of the app's geofences, or
getCurrentPosition() / watchPosition() with persist: true; later heartbeats carry that record's fix until the
anchor changes again. So the server can show "the device is on (last heartbeat 11:59), last position from 10:26".
Details in docs/heartbeat.md.
What costs battery, and the keys that change it (largest cost first):
| Cost | When | Config keys |
|---|---|---|
| GPS | While moving, and for stopTimeout minutes after the device stops (stop detection waits that long). | geolocation.desiredAccuracy ('balanced' uses Wi-Fi and cell towers, no GPS); locationUpdateInterval and fastestLocationUpdateInterval; stopTimeout (5 min by default); disableStopDetection: true keeps GPS on all the time: avoid it. |
| Mobile radio | Every upload request wakes the radio for several seconds. | http.syncInterval (the field-force setup uses 300 s: one upload about every 5 minutes while moving instead of one per record), batchSync, maxBatchSize, autoSyncThreshold, disableAutoSyncOnCellular. Heartbeats and audit records are always uploaded at once. |
| CPU wake-ups | Every heartbeat alarm; every fix while moving. | heartbeat.minInterval / maxInterval (180/300 s). With the battery-optimization exemption, heartbeats stay exact in Doze (about 20 wake-ups per hour at 180 s); without it, Android spaces them about 9 minutes apart. |
| Polygon geofences | Continuous location while the device is inside a polygon's enclosing circle. | Prefer circles. |
| Activity recognition | While tracking in start() mode. It uses the motion sensors and costs little. | activity.activityRecognitionInterval, activity.disableMotionActivityUpdates (then only the stationary region and passive fixes detect movement). |
| Logging | Every log line is written to a file. | logger.logLevel ('info' by default; 'debug' writes much more). |
How to measure the cost on a real phone: procedure M-04 in the e2e runbook.
Geofencing
- Up to 100 geofences (
TOO_MANY_GEOFENCESbeyond that). An identifier is 1–100 characters; adding a geofence with an existing identifier replaces it.loiteringDelaymust be ≥ 0. The identifier__lt_stationary__is reserved for the plugin's stationary region:addGeofencerejects it withINVALID_ARGUMENT. - The stationary region (see Battery) does not count against the 100. GMS accepts at most 100 geofences
per app, stationary region included. While the app has 99 or 100 geofences, the plugin does not register the
stationary region (stationary tracking then uses the low-power fallback). When an add brings the app to 99
geofences, the plugin removes a registered region after that add has finished; the removal waits for the tracking
engine and for the OS. So an
addGeofenceof the 100th geofence right after the 99th, or oneaddGeofencescall that goes from 98 to 100 geofences, can still fail withTOO_MANY_GEOFENCESfrom the OS while the region is registered (an open item in DECISIONS.md). - Circle:
latitude,longitudeand aradius> 0. - Polygon:
vertices, at least 3 distinct points with a non-zero area, not crossing the 180° meridian. The plugin registers the polygon's smallest enclosing circle, padded by 10 % (at least 50 m), with the OS;getGeofences()returns it aslatitude/longitude/radius. While the device is inside that circle, the plugin requests continuous fixes and tests them against the polygon. To avoid flapping at the edge, the state changes only when a fix's accuracy circle is entirely on one side of the edge, or when two fixes in a row agree. Leaving the circle also means leaving the polygon. PolygonDWELLis always computed by the plugin. - Geofences are stored in the plugin's database, but registered with the OS and reported only while tracking is
on (
start()orstartGeofences());stop()unregisters them. GMS and HMS drop all geofences when location services are switched off; the plugin registers them again when location comes back or the location permission level changes. If the backend answers that geofencing is not available yet (GMSGEOFENCE_NOT_AVAILABLE), the plugin tries again after 10, 30, 60, 120 and 300 s. - Backends. GMS and HMS report
ENTER,EXITandDWELLnatively; withinitialTriggerEntrytheir initial trigger isENTER | DWELL. Theandroidbackend (proximity alerts) has no dwell: the plugin synthesizesDWELLwhen the device stays inside forloiteringDelayafterENTER. It always reportsENTERfor a circle the device is already inside. - Every transition creates a
geofencerecord (uploaded like a location) and ageofenceevent.addGeofence(s)andremoveGeofence(s)emitgeofenceschange. removeGeofences()withoutidentifiers(or withidentifiers: null) removes all geofences;removeGeofences({ identifiers: [] })removes nothing, so an empty computed list never wipes everything. Unknown identifiers are ignored.- Background location permission ("Allow all the time") is needed for geofences to fire while the app is in the background.
Records, uploads and the queue
- Every record is written to SQLite first and uploaded to
http.urlaccording to the rules in docs/wire-format.md. Priority records (heartbeat,tracking_start,tracking_stop,providerchange) are uploaded right away and take the older queued records along. - A request answered
5xxor429, or without an answer (timeout, network error), is tried again in the same upload after 2, 4 and 8 seconds (at most 4 tries). Other answers are not tried again; a401refreshes the JWT and retries once (details). - With
syncInterval: 0(the default) there is no retry timer for failed uploads: queued records are retried when a record is inserted (at least every heartbeat while tracking), when the network comes back, when tracking starts, and onsync(). WithsyncIntervalabove 0, a timer uploads normal records that have become due, and retries them once persyncIntervalafter a failed upload (see below). getLocations({ limit })returns queued records, oldest first;getCount()counts them;destroyLocations()anddestroyLocation({ uuid })delete them without uploading.sync()uploads the whole queue now, ignoringautoSync,autoSyncThresholdanddisableAutoSyncOnCellular, and resolves with the uploaded records. It rejects withNO_URL(no validhttp.url), or withHTTP_ERROR/NETWORK_ERRORat the first failed upload, after its tries (records uploaded before the failure are already deleted).insertLocation({ location })stores a record of your own (defaultevent: 'location') and uploads it like the others. It emits no event and does not restart the heartbeat window, because it is not evidence that tracking runs.persistence.maxDaysToPersistandmaxRecordsToPersistprune the queue, including heartbeats and audit records.
Live location: http.syncInterval
A back office that shows where a worker is now needs a recent position, but one upload per location record keeps the
mobile radio busy. http.syncInterval sets the maximum age of the newest position on the server:
http: {
url: 'https://api.example.com/locations',
autoSync: true, // required: syncInterval works only with autoSync
syncInterval: 300, // seconds: live location at most about 5 minutes old
batchSync: true, // one request carries many records
maxBatchSize: 100,
}- Normal records (
location,motionchange,current_position,watch_position,geofence) wait in the queue until the oldest of them issyncIntervalseconds old. "Oldest" is the queued normal record with the smallestrecorded_at; its age is now −recorded_at, and a negative age (the wall clock was set back) counts as due. Then the whole queue is uploaded, in batches ofmaxBatchSizewhenbatchSyncis on. While the device moves, that is one upload about everysyncIntervalseconds. - Audit records (
heartbeat,tracking_start,tracking_stop,providerchange) are still uploaded at once, and they take the queued normal records with them. - While stationary, no location records are created; the heartbeat (every
minInterval) is uploaded at once and carries the last position with its acquisition time. autoSyncThresholdabove 0 is a size limit: the queue is uploaded earlier when it reaches that many records.- The check runs on every insert, when the network comes back, when the uploader starts, and on a timer in the
tracking process (at
oldest.recorded_at + syncInterval).disableAutoSyncOnCellularstill holds normal records back on cellular, andsync()still uploads everything at once. - The timer holds no wake lock. It counts only the time the CPU is awake, so in deep sleep or Doze it fires late. Two other triggers limit the delay: while the device moves, every new location record runs the check against the wall clock; while it is stationary, each heartbeat is uploaded at once and takes the queue with it. With the heartbeat disabled and no new record, held records wait until the CPU has been awake long enough.
- While tracking is off, normal records are uploaded as with
syncInterval: 0(byautoSyncThreshold), because there is no timer then. For example, with the defaultautoSyncThreshold: 0, agetCurrentPosition()record after the 02:00 stop is uploaded at once. When tracking stops, records that were held are handled by this rule at once. - After a failed automatic upload (its last try failed), normal records are retried once per
syncInterval, counted from that last try (measured on the elapsed-time clock), by the timer, not on every
