npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  • 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 motionchange and activitychange events. 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.minInterval seconds (default 180), the plugin creates a heartbeat record with the last known location. It should exist before maxInterval (default 300), and it is uploaded immediately. Its heartbeat object tells the server when the next one is due. See docs/heartbeat.md.
  • Audit records. tracking_start, tracking_stop (with a reason) and providerchange records 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 params into the body, and refresh a JWT on 401. Failed uploads stay queued and are retried, and recorded_at / sent_at expose late delivery. http.syncInterval batches 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, EXIT and DWELL transitions, and a geofences-only tracking mode (startGeofences()). See Geofencing.
  • One-off and watched positions. getCurrentPosition() (best of N samples) and watchPosition().
  • 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 android

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

  1. 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, hms and android (case and spaces don't matter). android alone 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.

  2. Its SDK classes are in the APK.

  3. The app's merged manifest declares the plugin's two receivers for it (provider.gms.GmsActivityReceiver and GmsGeofenceReceiver, or provider.hms.HmsActivityReceiver and HmsGeofenceReceiver). 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 Android LocationManager.
  • 'gms' or 'hms': that backend. If it is not packaged or not available on the phone, the plugin logs a warning and uses the Android LocationManager.
  • 'android': always the Android LocationManager.

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.

  1. In android/gradle.properties, stop the plugin from packaging any SDK:

    locationTracking.providers=none
  2. In 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; playServicesLocationVersion and hmsLocationVersion no longer apply.

  3. Add the Huawei Maven repository (step 2). For AppGallery Connect (step 3), put agconnect-services.json in android/app/src/hms/ instead of android/app/.

  4. 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 with locationProvider: 'gms' or 'hms' (it falls back to android with 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 set locationProvider.

    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 as com.huawei.systemmanager are phone makers' battery settings apps, not HMS, and the plugin uses them on every phone (step 6).

  5. Build both APKs: ./gradlew assembleGmsRelease assembleHmsRelease (or bundleGmsRelease for 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 hmsReleaseRuntimeClasspath lists the libraries in the AppGallery APK (gmsReleaseRuntimeClasspath for 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().packagedProviders shows 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:

  1. In AppGallery Connect, create a project and add an Android app whose package name matches your applicationId.
  2. 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 signingReport or keytool -list -v -keystore <keystore>.
  3. Make sure Location Kit is enabled under Project settings → Manage APIs.
  4. Download agconnect-services.json and put it in android/app/.
  5. Apply the AGC Gradle plugin. In android/build.gradle, add classpath 'com.huawei.agconnect:agcp:1.9.6.300' (or the latest version from the Huawei repository) to buildscript.dependencies. Then, in android/app/build.gradle, add apply plugin: 'com.huawei.agconnect' after apply 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 as location: 'granted'.
  • start() needs only foreground location. The foreground service keeps "while in use" access while it runs. Call start() 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().accuracy then reports 'approximate'.
  • Android 13+. POST_NOTIFICATIONS is 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_stop whose reason is service_start_failed.
  • Revoking a location permission in Settings kills the app's process. While tracking is on, the plugin records a providerchange the next time it runs; if location permission is gone completely, it also stops tracking with a tracking_stop whose reason is permission_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 (reason restore), with the persisted config.
  • It counts as the first ready() after install, so a later ready({ config, reset: false }) keeps the persisted config and ignores the one passed. Use reset: true or setConfig() 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's priority is min. It uses the tracking notification's small icon and color.
  • It is not posted when the session's stopAfterElapsedMinutes deadline has passed, and it disappears at that deadline.
  • It is removed when tracking starts by any path, when the app calls stop(), and when resume.enabled is 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 with PERMISSION_DENIED). It starts the foreground service, records a tracking_start (reason start), emits enabledchange, and resolves with the new State. The first motionchange (is_moving: false, with a fresh fix or the last known location) follows asynchronously, within locationTimeout + 5 s.

  • If Android refuses to start the foreground service right away, start() records a tracking_stop with reason service_start_failed and rejects with PERMISSION_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 location service 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 reason service_start_failed; listen for enabledchange.

  • startGeofences() works the same way in geofences-only mode (reason start_geofences). Calling start() while startGeofences() runs (or the reverse) switches the mode and records another tracking_start. Calling the same one twice does nothing.

  • stop() removes the location and activity updates, records a tracking_stop (reason stop) while the foreground service is still running, then stops the service, the heartbeat and the OS geofences, and emits enabledchange.

  • 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 a tracking_stop with that reason and emits enabledchange. When Android restarts the killed service by itself and the restart fails, the reason is permission_denied if location permission is no longer granted, and service_start_failed otherwise.

  • Tracking resumes by itself after the process was killed (reason restore), after a reboot (boot) and after an app update (package_replaced, both only with app.startOnBoot), and when ready() finds it enabled but not running. See docs/heartbeat.md for what Android allows. When Android refuses such a restore, notification.resume can 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 fake QUICKBOOT_POWERON broadcast without a real reboot creates no second tracking_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 desiredAccuracy and intervals. A location record is created when a fix is at least the (elastic) distanceFilter away 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 location records 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 than filter.trackingAccuracyThreshold); a moving activity (walking, running, on foot, on bicycle, in vehicle) at or above minimumActivityRecognitionConfidence lasts for motionTriggerDelay; or changePace({ isMoving: true }). Then the configured request (GPS for 'high') starts again and a motionchange (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 or changePace.
  • Moving → stationary (stop detection, unless disableStopDetection): stopTimeout minutes (at least 1) after the last evidence of motion, even if no more fixes arrive.
  • Each transition records a motionchange with the new is_moving. changePace({ isMoving }) forces a transition; it is ignored while tracking is off or in geofences-only mode.
  • Without activity recognition (the android backend, 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):

  1. 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;
  2. 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 than filter.trackingAccuracyThreshold (a coarse fix alone never wakes GPS);
  3. activity recognition reports walking, running, on foot, on bicycle or in vehicle, with at least minimumActivityRecognitionConfidence, for motionTriggerDelay;
  4. 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_GEOFENCES beyond that). An identifier is 1–100 characters; adding a geofence with an existing identifier replaces it. loiteringDelay must be ≥ 0. The identifier __lt_stationary__ is reserved for the plugin's stationary region: addGeofence rejects it with INVALID_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 addGeofence of the 100th geofence right after the 99th, or one addGeofences call that goes from 98 to 100 geofences, can still fail with TOO_MANY_GEOFENCES from the OS while the region is registered (an open item in DECISIONS.md).
  • Circle: latitude, longitude and a radius > 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 as latitude / 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. Polygon DWELL is 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() or startGeofences()); 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 (GMS GEOFENCE_NOT_AVAILABLE), the plugin tries again after 10, 30, 60, 120 and 300 s.
  • Backends. GMS and HMS report ENTER, EXIT and DWELL natively; with initialTriggerEntry their initial trigger is ENTER | DWELL. The android backend (proximity alerts) has no dwell: the plugin synthesizes DWELL when the device stays inside for loiteringDelay after ENTER. It always reports ENTER for a circle the device is already inside.
  • Every transition creates a geofence record (uploaded like a location) and a geofence event. addGeofence(s) and removeGeofence(s) emit geofenceschange.
  • removeGeofences() without identifiers (or with identifiers: 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.url according 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 5xx or 429, 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; a 401 refreshes 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 on sync(). With syncInterval above 0, a timer uploads normal records that have become due, and retries them once per syncInterval after a failed upload (see below).
  • getLocations({ limit }) returns queued records, oldest first; getCount() counts them; destroyLocations() and destroyLocation({ uuid }) delete them without uploading.
  • sync() uploads the whole queue now, ignoring autoSync, autoSyncThreshold and disableAutoSyncOnCellular, and resolves with the uploaded records. It rejects with NO_URL (no valid http.url), or with HTTP_ERROR / NETWORK_ERROR at the first failed upload, after its tries (records uploaded before the failure are already deleted).
  • insertLocation({ location }) stores a record of your own (default event: '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.maxDaysToPersist and maxRecordsToPersist prune 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 is syncInterval seconds old. "Oldest" is the queued normal record with the smallest recorded_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 of maxBatchSize when batchSync is on. While the device moves, that is one upload about every syncInterval seconds.
  • 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.
  • autoSyncThreshold above 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). disableAutoSyncOnCellular still holds normal records back on cellular, and sync() 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 (by autoSyncThreshold), because there is no timer then. For example, with the default autoSyncThreshold: 0, a getCurrentPosition() 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