@bricks-soft/capacitor-location-tracking 8.0.1 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +476 -147
  4. package/android/build.gradle +3 -1
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  7. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  15. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  17. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  18. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  19. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  20. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  23. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  26. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  33. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  34. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  37. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  39. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  40. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  42. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  43. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  44. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  51. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  53. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  54. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  55. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  56. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  57. package/dist/esm/definitions.d.ts +117 -8
  58. package/dist/esm/definitions.js.map +1 -1
  59. package/dist/esm/events.d.ts +8 -2
  60. package/dist/esm/events.js +7 -1
  61. package/dist/esm/events.js.map +1 -1
  62. package/dist/esm/index.d.ts +1 -1
  63. package/dist/esm/index.js +1 -1
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/web/config.js +10 -0
  66. package/dist/esm/web/config.js.map +1 -1
  67. package/dist/esm/web/device.d.ts +1 -1
  68. package/dist/esm/web/device.js +8 -1
  69. package/dist/esm/web/device.js.map +1 -1
  70. package/dist/esm/web.d.ts +0 -8
  71. package/dist/esm/web.js +12 -3
  72. package/dist/esm/web.js.map +1 -1
  73. package/dist/plugin.cjs.js +39 -5
  74. package/dist/plugin.cjs.js.map +1 -1
  75. package/dist/plugin.js +39 -5
  76. package/dist/plugin.js.map +1 -1
  77. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  78. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  79. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  80. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  81. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  82. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  83. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  84. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  85. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  86. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  87. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  88. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  89. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  90. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  91. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  92. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  93. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  94. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  95. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  96. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  97. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  98. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  99. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  100. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  101. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  102. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  103. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  104. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  105. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  106. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  107. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  108. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  109. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  110. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  111. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  112. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  113. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  114. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  115. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  116. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  117. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  118. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  119. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  120. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  121. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  122. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  123. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  124. package/package.json +10 -4
package/README.md CHANGED
@@ -28,6 +28,8 @@ whether the user really kept tracking on.
28
28
  - [Companion plugins (native API)](#companion-plugins-native-api)
29
29
  - [Error codes](#error-codes)
30
30
  - [Web stub](#web-stub)
31
+ - [Stored data and backups](#stored-data-and-backups)
32
+ - [Device information on records](#device-information-on-records)
31
33
  - [Server side](#server-side)
32
34
  - [Example app](#example-app)
33
35
  - [Field-force example](#field-force-example)
@@ -72,7 +74,7 @@ whether the user really kept tracking on.
72
74
  | Platform | Status |
73
75
  |---|---|
74
76
  | Android (API 24+, Kotlin) | ✔ Supported |
75
- | iOS | Planned for a later phase. Until then every call rejects with "not implemented on ios". |
77
+ | iOS 15+ | Native route tracking, heartbeat, restore, positions and HTTP sync. Geofencing on iOS 17+, up to 20 geofences. |
76
78
  | 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](#web-stub). |
77
79
 
78
80
  ## Installation
@@ -143,11 +145,22 @@ does not use it. The `locationProvider` values:
143
145
  - `'android'`: always the Android `LocationManager`.
144
146
 
145
147
  The backend is chosen once per process and again whenever `locationProvider` changes (while tracking, the location
146
- request, activity updates and geofences move to the new backend). `getState().backend` and `getDeviceInfo()`
147
- (`gmsAvailable`, `hmsAvailable`, `packagedProviders`) show what was selected.
148
+ request, activity updates and geofences move to the new backend). With `'auto'`, the plugin also checks when tracking
149
+ starts, on every `providerchange` and on every heartbeat whether a better backend has become available (for example
150
+ Google Play services finished an update), and then moves to it. `getState().backend` and `getDeviceInfo()` show what
151
+ was selected: `gmsAvailable`, `hmsAvailable`, `packagedProviders`, and why a better backend was not used
152
+ (`backendFallbackReason`, with the raw availability codes `gmsStatus` and `hmsStatus`). `hmsCoreOutdated` is `true`
153
+ when HMS Core on the phone is older than 6.3.0.301, which Huawei names for Location Kit 6.x (the plugin still uses it
154
+ and logs a warning). `locationAvailableHint` is the last `LocationAvailability` from GMS or HMS; Google documents that
155
+ it can be wrong in both directions, so it is a hint only.
148
156
 
149
157
  The `android` backend has two limitations: it has **no activity recognition**, so motion detection relies on distance
150
158
  only, and **no native geofence dwell**, so the plugin synthesizes `DWELL` itself (see [Geofencing](#geofencing)).
159
+ For `'high'` and `'balanced'` accuracy it registers both the `gps` and the `network` provider, so a phone indoors
160
+ without satellites still gets network fixes; while a GPS fix arrived in the last 10 s (or two update intervals, if
161
+ longer), network fixes are dropped, so GPS and network points do not alternate. A fix without an accuracy value is
162
+ treated as 1,000 km inaccurate, so `filter.trackingAccuracyThreshold` drops it. `getCurrentPosition()` asks the
163
+ enabled providers at the same time and keeps the most accurate fix that arrives within 3 s of the first one.
151
164
 
152
165
  **Release builds (R8 / minify).** Nothing to add: the plugin ships consumer ProGuard/R8 rules that keep its provider
153
166
  bundles and the GMS/HMS class names it probes by reflection, so a minified release app still detects Google Play
@@ -345,6 +358,17 @@ If you don't want a permission, remove it in your app's manifest and turn off th
345
358
  </manifest>
346
359
  ```
347
360
 
361
+ **Apps that track only while they are in use.** Because the plugin's manifest declares `ACCESS_BACKGROUND_LOCATION`,
362
+ Google Play asks every app that uses the plugin for the background-location declaration. An app that never asks for
363
+ "Allow all the time" should remove the permission, so that Play does not ask:
364
+
365
+ ```xml
366
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove"/>
367
+ ```
368
+
369
+ Without it, geofences do not fire in the background, and on Android 11 and newer tracking is not resumed from the
370
+ background (after a reboot, an app update or process death); it resumes when the app opens and calls `ready()`.
371
+
348
372
  ### 5. Runtime permission flow
349
373
 
350
374
  `requestPermissions()` asks, by default, for all four permissions in this order: `location`, `notifications`,
@@ -365,7 +389,10 @@ resolves with the resulting `PermissionStatus`. Overlapping calls run one after
365
389
  `backgroundPermissionRationale`; if the user declines it, or no Activity is visible, the step is skipped. Google
366
390
  Play requires your own in-app disclosure before this, plus a declaration in the Play Console.
367
391
  - **Android 12+.** The user may grant only *approximate* location. `getProviderState().accuracy` then reports
368
- `'approximate'`.
392
+ `'approximate'`. Approximate fixes are accurate to only a few kilometres, so `filter.trackingAccuracyThreshold`
393
+ (default 100 m) drops all of them: no `location` or `motionchange` records with coords are created, and only
394
+ heartbeats and audit records (`tracking_start`, `tracking_stop`, `providerchange`) are uploaded. Check
395
+ `getProviderState().accuracy` and ask the user for precise location if your app needs positions.
369
396
  - **Android 13+.** `POST_NOTIFICATIONS` is a runtime permission. If it is denied, tracking still works, but the
370
397
  foreground-service notification is hidden.
371
398
  - **Below Android 10**, there is no separate background permission (it is reported the same as `location`), and
@@ -373,8 +400,10 @@ resolves with the resulting `PermissionStatus`. Overlapping calls run one after
373
400
  also reported as `'granted'`.
374
401
  - A permission whose Android permission you removed from the app's manifest (`tools:node="remove"`) is not requested.
375
402
  - Background location is needed for geofencing and for resuming tracking after a reboot, an app update or process
376
- death. On Android 14+ a resume from the background without it ends with a `tracking_stop` whose reason is
377
- `service_start_failed`.
403
+ death. On Android 11 and newer, a resume from the background without it ends with a `tracking_stop` whose reason
404
+ is `service_start_failed`: Android 14+ refuses to start the service, and on Android 11 to 13 the service would start
405
+ but get no location, so the plugin does not start it. [`notification.resume`](#confignotificationresume) lets the
406
+ user resume with a tap. Opening the app (`ready()`) also resumes tracking.
378
407
  - Revoking a location permission in Settings kills the app's process. While tracking is on, the plugin records a
379
408
  `providerchange` the next time it runs; if location permission is gone completely, it also stops tracking with a
380
409
  `tracking_stop` whose reason is `permission_denied`.
@@ -488,9 +517,24 @@ instead.
488
517
 
489
518
  ## Configuration reference
490
519
 
491
- All options are optional. `getState().config` returns the full config with every default filled in (including the
492
- JWT tokens). Out-of-range values are clamped to the limits given below, and every clamp is logged as a warning;
493
- numbers that are not finite become the default.
520
+ All options are optional. `getState().config` returns the full config with every default filled in. The secret
521
+ values of `http.authorization` are masked there (see [Tokens](#tokens-storage-and-masking)). Out-of-range values are
522
+ clamped to the limits given below, and every clamp is logged as a warning; numbers that are not finite become the
523
+ default.
524
+
525
+ `ready()`, `setConfig()` and `reset()` also check the values that would otherwise fail only at upload time, and
526
+ reject with `INVALID_ARGUMENT` (nothing is applied):
527
+
528
+ - `http.url` (when set): an `http` or `https` URL with a host, without user info (`user:password@`) and without a
529
+ fragment (`#...`).
530
+ - `http.headers` and `http.authorization.refreshHeaders`: names and values that HTTP allows (ASCII, no line breaks).
531
+ The error names the header but never repeats its value.
532
+ - `http.locationTemplate` and `http.geofenceTemplate`: every `<%= name %>` placeholder exists, and the template
533
+ renders to a JSON object or array, both for a record with every field set and for one with no location.
534
+
535
+ Values that are accepted but probably wrong are listed in the `warnings` array of the `State` these calls resolve
536
+ with. Today the only warning is an `http://` URL: Android 9+ blocks cleartext traffic unless the app's network
537
+ security config allows that host.
494
538
 
495
539
  ### `config` (top level)
496
540
 
@@ -510,7 +554,7 @@ numbers that are not finite become the default.
510
554
  | `elasticityMultiplier` | number (≥ 0) | `1` | Elastic filter: `distanceFilter × max(1, round(speed / 5) × elasticityMultiplier)`. |
511
555
  | `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. |
512
556
  | `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. |
513
- | `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](#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. |
557
+ | `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](#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. The time is counted on the phone's elapsed-time clock, so changing the wall clock does not move the stop; after a reboot it is counted from the wall-clock start time. |
514
558
  | `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. |
515
559
  | `locationTimeout` | number (ms, > 0) | `30000` | Default timeout of `getCurrentPosition()`, and of the first fix after `start()`. |
516
560
  | `filter` | object | see below | Location filters. |
@@ -543,12 +587,13 @@ numbers that are not finite become the default.
543
587
  | `enabled` | boolean | `true` | Creates `heartbeat` records while tracking is on. |
544
588
  | `minInterval` | number (s, ≥ 60) | `180` | A heartbeat is due when no record has been created for this long. |
545
589
  | `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. |
590
+ | `uploadKeepAwake` | number (s, 0–60) | `10` | After a heartbeat is created, the plugin keeps the CPU awake (a partial wake lock) until the upload that sends it has finished, for at most this many seconds. So the heartbeat reaches the server when it is created, not when the phone next wakes up. `0` turns it off. |
546
591
 
547
592
  ### `config.http`
548
593
 
549
594
  | Option | Type | Default | Description |
550
595
  |---|---|---|---|
551
- | `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. |
596
+ | `url` | string \| null | none | Upload endpoint. Without a URL nothing is uploaded, and records stay queued. An invalid URL is rejected by `setConfig()` (see above); an `http://` URL gives a warning. |
552
597
  | `method` | `'POST' \| 'PUT' \| 'PATCH'` | `'POST'` | HTTP method. |
553
598
  | `headers` | object | `{}` | Extra request headers. They can override `Content-Type`; an `Authorization` header turns the JWT handling off. |
554
599
  | `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: '.'`. |
@@ -562,6 +607,7 @@ numbers that are not finite become the default.
562
607
  | `locationTemplate` | string \| null | `null` | JSON text with `<%= name %>` placeholders that replaces the default record shape. |
563
608
  | `geofenceTemplate` | string \| null | `null` | Template for `geofence` records. Falls back to `locationTemplate`. |
564
609
  | `timeout` | number (ms, > 0) | `60000` | Request timeout (call, read and write). |
610
+ | `maxRetryAfter` | number (s, ≥ 0) | `3600` | Longest wait the plugin accepts from a `Retry-After` header of a `429` or `503` response. A longer value is cut to this. |
565
611
  | `authorization` | object \| null | `null` | JWT settings, see below. |
566
612
 
567
613
  The upload rules (priority records, retries, templates, body shapes) are described in
@@ -580,6 +626,38 @@ The upload rules (priority records, retries, templates, body shapes) are describ
580
626
  | `refreshPayloadEncoding` | `'json' \| 'form'` | `'json'` | Body encoding of the refresh request. |
581
627
  | `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. |
582
628
 
629
+ #### Tokens: storage and masking
630
+
631
+ - `accessToken`, `refreshToken`, `refreshPayload` and `refreshHeaders` are stored encrypted (AES-256-GCM with a key
632
+ that stays in the Android Keystore) in a file under `noBackupFilesDir`, not in the SharedPreferences file. Tokens
633
+ that an older version stored in plain text are moved there on the first start.
634
+ - If the stored tokens cannot be decrypted (for example the Keystore key is gone), they read as unset: uploads that
635
+ need them wait, an `authorization` event reports the error, and new tokens from `setConfig()` end the wait.
636
+ - `getState()` and the `State` of `ready()`, `setConfig()` and `reset()` return these values masked: `"***"`, or
637
+ `"***"` plus the last 4 characters for values longer than 8 characters. The string values of `refreshPayload` are
638
+ masked one by one; a value that contains `{refreshToken}` is shown as it is. The `authorization` event still carries the full
639
+ server response, which apps need to read new tokens.
640
+ - A masked value given back unchanged in `setConfig()` (for example the whole `getState().config`) keeps the stored
641
+ value. Any other value replaces it.
642
+ - One upload uses one token: the token read when the upload starts, or the token refreshed from it after a `401`.
643
+ If the app changes the authorization during an upload, the upload stops and the records stay queued. If
644
+ `http.authorization` is still set, a new upload with the new tokens starts about 10 s later (also when tracking is
645
+ off or the app's process has ended, through the upload job); automatic uploads wait until then, `sync()` does not. If the app removed the authorization (step 3 below),
646
+ nothing starts until the next trigger (a new record, the network returning, `sync()`). So an app that sets the next
647
+ user's tokens without the logout steps below gets the queued records uploaded with them. A refresh that finishes
648
+ after the app set new tokens does not overwrite them.
649
+
650
+ #### Logging out and switching users
651
+
652
+ The queue does not remember which user recorded a record. To make sure one user's records are never uploaded with
653
+ another user's token or `params`, log out in this order:
654
+
655
+ 1. `await LocationTracking.stop()`;
656
+ 2. `await LocationTracking.sync()` to upload the queued records with the current user's settings, or
657
+ `await LocationTracking.destroyLocations()` to delete them;
658
+ 3. remove the authorization: `await LocationTracking.setConfig({ http: { authorization: null } })`;
659
+ 4. configure the new user (`setConfig` with the new token, `params` and `extras`), then `start()` again.
660
+
583
661
  ### `config.persistence`
584
662
 
585
663
  | Option | Type | Default | Description |
@@ -645,7 +723,12 @@ session resumes with the persisted config and its original `stopAfterElapsedMinu
645
723
  | Option | Type | Default | Description |
646
724
  |---|---|---|---|
647
725
  | `logLevel` | `'off' \| 'error' \| 'warn' \| 'info' \| 'debug' \| 'verbose'` | `'info'` | Minimum level written to the plugin log. |
648
- | `logMaxDays` | number (≥ 1) | `3` | Days of log files to keep. |
726
+ | `logMaxDays` | number (≥ 1) | `3` | Days of log files to keep. All log files together are also kept under 10 MB: the oldest daily files are deleted first (never today's). |
727
+
728
+ Text that the app writes with `log()` is cleaned before it reaches logcat and the log files (which `emailLog()` and
729
+ `uploadLog()` send): values of credential fields (`accessToken`, `refresh_token`, `password`, `secret`, `apiKey`,
730
+ `authorization`, …) become `[redacted]`, `Bearer <token>` becomes `Bearer [redacted]`, JSON Web Tokens become `[jwt]`
731
+ and URLs become `[url]`.
649
732
 
650
733
  ### `config.backgroundPermissionRationale`
651
734
 
@@ -683,6 +766,11 @@ The dialog shown before the Android 11+ "Allow all the time" settings page.
683
766
  one twice does nothing.
684
767
  - `stop()` removes the location and activity updates, records a `tracking_stop` (reason `stop`) while the foreground
685
768
  service is still running, then stops the service, the heartbeat and the OS geofences, and emits `enabledchange`.
769
+ The change of `enabled` is written to disk before `stop()` resolves, so a process killed right after `stop()` does
770
+ not bring tracking back.
771
+ - Every `enabledchange` event carries the `reason` of the change (the `tracking_start` or `tracking_stop` reason, for
772
+ example `start`, `stop`, `stop_after_elapsed` or `service_start_failed`). `getState()` returns the reason and time
773
+ of the last `tracking_stop` as `lastStopReason` and `lastStopAt`.
686
774
  - Tracking also stops by itself: `stopOnStationary`, `stopAfterElapsedMinutes`, `stopOnTerminate` (app swiped away),
687
775
  a revoked location permission (`permission_denied`), or Android refusing the foreground service
688
776
  (`service_start_failed`). Each case records a `tracking_stop` with that reason and emits `enabledchange`. When
@@ -690,7 +778,13 @@ The dialog shown before the Android 11+ "Allow all the time" settings page.
690
778
  permission is no longer granted, and `service_start_failed` otherwise.
691
779
  - Tracking resumes by itself after the process was killed (reason `restore`), after a reboot (`boot`) and after an
692
780
  app update (`package_replaced`, both only with `app.startOnBoot`), and when `ready()` finds it enabled but not
693
- running. See [docs/heartbeat.md](docs/heartbeat.md#android-reliability) for what Android allows. When Android
781
+ running.
782
+ - **User stop.** After the user stops the app (Settings → Force stop, or the Stop button of the Android 13+ Task
783
+ Manager), tracking stays off until the app is opened again: a broadcast, alarm or service restart that starts the
784
+ process does not resume it. The plugin records one `tracking_stop` with reason `user_stopped` and `stopped_at` (the
785
+ time of the stop, from Android's exit record), releases the leftover activity, geofence and heartbeat
786
+ registrations, and keeps `enabled` set, so `ready()` resumes tracking when the app opens. A boot or app-update
787
+ restore is not checked (on Android 11 and 12 an update can be recorded like a user stop). See [docs/heartbeat.md](docs/heartbeat.md#android-reliability) for what Android allows. When Android
694
788
  refuses such a restore, [`notification.resume`](#confignotificationresume) can offer a tap that resumes it
695
789
  (`resume_notification`).
696
790
  - A boot broadcast restores tracking once per boot. The plugin reads the phone's boot counter
@@ -720,13 +814,17 @@ The dialog shown before the Android 11+ "Allow all the time" settings page.
720
814
  last evidence of motion, even if no more fixes arrive.
721
815
  - Each transition records a `motionchange` with the new `is_moving`. `changePace({ isMoving })` forces a transition;
722
816
  it is ignored while tracking is off or in geofences-only mode.
817
+ - **Location request errors.** If the OS rejects the location request (for example a missing permission, or GMS or
818
+ HMS reporting an error later), the plugin emits a `locationerror` event and requests it again after 10 s, doubling
819
+ the wait up to 5 minutes; the next fix resets the wait. Tracking stays on.
723
820
  - Without activity recognition (the `android` backend, `disableMotionActivityUpdates`, or no activity permission),
724
821
  motion is detected from the stationary region and from distance only.
725
822
 
726
823
  **Positions.** `getCurrentPosition()` works whether or not tracking is on. It needs location permission
727
824
  (`PERMISSION_DENIED`) and enabled location services (`LOCATION_DISABLED`). It takes up to `samples` fixes, stops
728
825
  early on a fix of 10 m or better, and resolves with the most accurate one; at the timeout it resolves with the best
729
- fix so far, or rejects with `TIMEOUT` if there is none. With `maximumAge`, a recent enough known fix is returned
826
+ fix so far, or rejects with `TIMEOUT` if there is none. If the OS rejects the location request, it rejects at once
827
+ with that error (for example `PERMISSION_DENIED` or `UNAVAILABLE`) instead of waiting for the timeout. With `maximumAge`, a recent enough known fix is returned
730
828
  right away. With `persist: true` (default) the fix is stored as a `current_position` record, which is uploaded,
731
829
  emitted as a `location` event and restarts the heartbeat window. `watchPosition()` reports each fix (or an error,
732
830
  such as `PERMISSION_DENIED`) to its callback until `clearWatch()`; it doesn't report disabled location services as
@@ -816,7 +914,7 @@ How to measure the cost on a real phone: procedure M-04 in the
816
914
 
817
915
  ## Geofencing
818
916
 
819
- - Up to **100** geofences (`TOO_MANY_GEOFENCES` beyond that). An identifier is 1–100 characters; adding a geofence
917
+ - Up to **100** geofences on Android and **20** on iOS (`TOO_MANY_GEOFENCES` beyond that). An identifier is 1–100 characters; adding a geofence
820
918
  with an existing identifier replaces it. `loiteringDelay` must be ≥ 0. The identifier `__lt_stationary__` is
821
919
  reserved for the plugin's stationary region: `addGeofence` rejects it with `INVALID_ARGUMENT`.
822
920
  - The stationary region (see [Battery](#battery)) does not count against the 100. GMS accepts at most 100 geofences
@@ -856,12 +954,27 @@ How to measure the cost on a real phone: procedure M-04 in the
856
954
  [docs/wire-format.md](docs/wire-format.md#when-uploads-happen). Priority records (`heartbeat`, `tracking_start`,
857
955
  `tracking_stop`, `providerchange`) are uploaded right away and take the older queued records along.
858
956
  - A request answered `5xx` or `429`, or without an answer (timeout, network error), is **tried again in the same
859
- upload** after 2, 4 and 8 seconds (at most 4 tries). Other answers are not tried again; a `401` refreshes the JWT
860
- and retries once ([details](docs/wire-format.md#response-handling-and-retries)).
861
- - With `syncInterval: 0` (the default) there is **no retry timer** for failed uploads: queued records are retried when
862
- a record is inserted (at least every heartbeat while tracking), when the network comes back, when tracking starts,
863
- and on `sync()`. With `syncInterval` above 0, a timer uploads normal records that have become due, and retries them
864
- once per `syncInterval` after a failed upload (see below).
957
+ upload** after about 2, 4 and 8 seconds (at most 4 tries; each wait is moved by up to 20 % up or down, so many
958
+ phones do not retry at the same moment). A `429` or `503` with a `Retry-After` longer than the next wait ends the
959
+ upload. Other answers are not tried again; a `401` refreshes the JWT and retries once
960
+ ([details](docs/wire-format.md#response-handling-and-retries)).
961
+ - Redirects are not followed: a `3xx` answer is a failed upload and the records stay queued. Only the first 16 KB of
962
+ an upload answer are read (for the `http` event's `responseText`).
963
+ - A `413` (request too large) for a batch halves the batch size and sends the same records again in smaller batches.
964
+ - **Wait after a failed upload.** When the last try of an automatic upload fails, the plugin stores the earliest time
965
+ of the next automatic upload of normal records: 30 s after the first failure, doubling with each further failure up
966
+ to 15 minutes (each moved by up to 20 %), and at least the server's `Retry-After` (up to `http.maxRetryAfter`).
967
+ After a `401` that a token refresh cannot fix, the wait is 15 minutes, and new tokens from `setConfig()` end it at
968
+ once. The stored time survives an app restart. The network coming back ends a server wait (not an authorization
969
+ wait), and a successful upload ends both. Priority records and `sync()` are not held back.
970
+ - **Uploads when the app is not running.** The plugin schedules an Android job (JobScheduler) for queued records: when
971
+ the device is offline it runs as soon as a network is available, after a failed upload it runs at the stored next
972
+ time, and when tracking stops, or an upload starts while tracking is off, it is scheduled to run right away once. So
973
+ records are uploaded also after the app's process has ended, or when Android freezes it in the background.
974
+ On Android 12+ the job is expedited when it should run at once.
975
+ - Records are uploaded when a record is inserted (at least every heartbeat while tracking), when the network comes
976
+ back, when tracking starts, by the upload job, and on `sync()`. With `syncInterval` above 0, a timer also uploads
977
+ normal records that have become due (see below).
865
978
  - `getLocations({ limit })` returns queued records, oldest first; `getCount()` counts them; `destroyLocations()` and
866
979
  `destroyLocation({ uuid })` delete them without uploading.
867
980
  - `sync()` uploads the whole queue now, ignoring `autoSync`, `autoSyncThreshold` and `disableAutoSyncOnCellular`, and
@@ -869,8 +982,8 @@ How to measure the cost on a real phone: procedure M-04 in the
869
982
  `NETWORK_ERROR` at the first failed upload, after its tries (records uploaded before the failure are already
870
983
  deleted).
871
984
  - `insertLocation({ location })` stores a record of your own (default `event: 'location'`) and uploads it like the
872
- others. It emits **no** event and does **not** restart the heartbeat window, because it is not evidence that
873
- tracking runs.
985
+ others. `location.coords` needs `latitude`, `longitude` and an `accuracy` (m) greater than 0. It emits **no**
986
+ event and does **not** restart the heartbeat window, because it is not evidence that tracking runs.
874
987
  - `persistence.maxDaysToPersist` and `maxRecordsToPersist` prune the queue, including heartbeats and audit records.
875
988
 
876
989
  ### Live location: `http.syncInterval`
@@ -940,9 +1053,11 @@ tracking has been started (or resumed) in the app's process.
940
1053
  | `http` | `{ success, status, responseText, uuids }` | An upload request finished. There is one event per HTTP request: a `401` followed by a token refresh and a retry gives two, and every try of an upload that is tried again gives one. `status` is `0` for a network error. |
941
1054
  | `connectivitychange` | `{ connected, type }` | The network connection changed. A network blocked for the app by Doze or Data Saver counts as disconnected. |
942
1055
  | `powersavechange` | `{ isPowerSaveMode }` | Battery saver was turned on or off. |
943
- | `enabledchange` | `{ enabled }` | Tracking was started or stopped, including automatic stops (`stopOnStationary`, `stopAfterElapsedMinutes`, `terminate`, `permission_denied`, `service_start_failed`, `reboot`, `package_replaced`). Not emitted when tracking resumes in a new process. |
1056
+ | `enabledchange` | `{ enabled, reason }` | Tracking was started or stopped, including automatic stops. `reason` is the reason of the `tracking_start` or `tracking_stop` record: `start`, `start_geofences`, `resume_notification`, `stop`, `stop_on_stationary`, `stop_after_elapsed`, `terminate`, `permission_denied`, `service_start_failed`, `reboot` or `package_replaced`. Not emitted when tracking resumes in a new process. |
944
1057
  | `notificationaction` | `{ id }` | A notification action button was tapped. |
945
- | `authorization` | `{ success, status, error?, response? }` | A JWT refresh was attempted, successfully or not. `response` is the parsed JSON body of the refresh response. |
1058
+ | `authorization` | `{ success, status, error?, response? }` | A JWT refresh was attempted, successfully or not, or the stored tokens cannot be decrypted (`success: false`, `status: 0`, `error` says to set the tokens again). `response` is the parsed JSON body of the refresh response. |
1059
+ | `locationerror` | `{ code, message, backend, retryInMs }` | The OS rejected the location request (`code` is an [error code](#error-codes) such as `PERMISSION_DENIED` or `UNAVAILABLE`). Tracking stays on and the request is made again after `retryInMs` (10 s, doubling up to 5 minutes; `null` when no retry is scheduled). |
1060
+ | `storagefull` | `{ uuid, event, diskFreeBytes }` | A record could not be stored because the phone's storage is full. `diskFreeBytes` is the free space of the app's storage, or `null`. Show the user a "storage full" message: until space is freed, records are lost. `insertLocation()` sends no event; it rejects with `IO_ERROR`. |
946
1061
 
947
1062
  ## Companion plugins (native API)
948
1063
 
@@ -1035,7 +1150,7 @@ Every rejected promise has a `code` (see the `ErrorCode` type) and a `message`.
1035
1150
  | `NO_URL` | `sync()` was called without a valid `http.url`. |
1036
1151
  | `HTTP_ERROR` | `sync()` got a non-2xx response. |
1037
1152
  | `NETWORK_ERROR` | `sync()` failed with a network or I/O error. |
1038
- | `TOO_MANY_GEOFENCES` | Adding the geofences would exceed 100 (or the OS geofence limit reported by GMS/HMS). |
1153
+ | `TOO_MANY_GEOFENCES` | Adding the geofences would exceed 100 on Android, 20 on iOS (or the OS geofence limit reported by GMS/HMS). |
1039
1154
  | `NO_ACTIVITY` | `emailLog()` was called while no Activity is attached. (The `open*Settings` methods work without one.) |
1040
1155
  | `IO_ERROR` | Storage or file error (database, log files). |
1041
1156
  | `UNIMPLEMENTED` | Not available on this platform (web stub, iOS). |
@@ -1071,6 +1186,40 @@ nothing and cannot track.
1071
1186
  (the status stays `'prompt'`).
1072
1187
  - **Everything else** rejects with `UNIMPLEMENTED`.
1073
1188
 
1189
+ ## Stored data and backups
1190
+
1191
+ The plugin keeps its SQLite queue (`location_tracking.db`), its log files and the encrypted tokens under the app's
1192
+ `noBackupFilesDir`, so Android Auto Backup and device-to-device transfer never copy them. Files of an older version are
1193
+ moved there on the first start, without losing records. The SharedPreferences file (config and runtime state) stays
1194
+ where it was and can be part of a backup or transfer; it holds no tokens.
1195
+
1196
+ When that file is restored onto another installation (a backup restore or a new phone), the plugin notices it (the
1197
+ stored install time differs from the app's) and never turns tracking on by itself: `enabled` is cleared, the
1198
+ authorization tokens are removed and the boot counters are reset. The rest of the config (URL, intervals, ...) is
1199
+ kept. The app logs the user in again and calls `start()`.
1200
+
1201
+ ## Device information on records
1202
+
1203
+ Some records carry readings that explain gaps in tracking (all without extra permissions):
1204
+
1205
+ | Field | On | Content |
1206
+ |---|---|---|
1207
+ | `network` | every record | Connection type when the record was created: `wifi`, `cellular`, `ethernet`, `other` or `none`. |
1208
+ | `device.device_id` | every record | The device id: on Android, `Settings.Secure.ANDROID_ID`; other platforms will fill it with their own id. Read once per process. Not in records queued by an older version. |
1209
+ | `device.background_restricted`, `device.standby_bucket` | `heartbeat` | The user set battery use to "Restricted"; the app standby bucket (`active`, `working_set`, `frequent`, `rare`, `restricted`). Android 9+. |
1210
+ | `device.disk_free_bytes` | `heartbeat` | Free space of the storage that holds the app's files. |
1211
+ | `device.thermal_status` | `heartbeat` | `none`, `light`, `moderate`, `severe`, `critical`, `emergency` or `shutdown`. Android 10+. |
1212
+ | `battery.temperature`, `battery.health`, `battery.plugged` | `heartbeat` | °C; `good`, `overheat`, `dead`, `over_voltage`, `cold`, `failure`, `unknown`; `ac`, `usb`, `wireless`, `dock`, `none`. |
1213
+ | `device.manufacturer`, `model`, `os_version`, `sdk_int`, `low_ram_device`, `ram_total_bytes` | `tracking_start` | The phone, its Android version and its memory. |
1214
+
1215
+ A reading that the Android version does not have, or that fails, is left out. Templates use them through the
1216
+ placeholders listed in [docs/wire-format.md](docs/wire-format.md#templates).
1217
+
1218
+ **Google Play Data safety.** Every record includes `device.device_id` (`Settings.Secure.ANDROID_ID` on Android), an ID
1219
+ that identifies the phone for your app. An app that uploads records must declare **"Device or other IDs"** in the Data safety
1220
+ form of the Google Play Console (collected, together with the purpose, for example "App functionality" or
1221
+ "Analytics").
1222
+
1074
1223
  ## Server side
1075
1224
 
1076
1225
  - [docs/wire-format.md](docs/wire-format.md) describes every record variant, batching, templates, upload and retry
@@ -1081,7 +1230,7 @@ nothing and cannot track.
1081
1230
  to audit tracking gaps on the server.
1082
1231
  - [docs/device-test-checklist.md](docs/device-test-checklist.md) is a manual test plan for real GMS, HMS and
1083
1232
  non-GMS phones; [docs/e2e-runbook.md](docs/e2e-runbook.md) covers the emulator tests and the real-phone procedures
1084
- M-01 … M-08.
1233
+ M-01 … M-12.
1085
1234
 
1086
1235
  In short, store records idempotently by `uuid` and answer `2xx` quickly (also for records you will never accept, or
1087
1236
  they are retried until pruned). While tracking is on, expect some record at least every `maxInterval` seconds (about
@@ -1091,11 +1240,15 @@ next, so a normal 9-minute gap in Doze is not flagged as a failure (see
1091
1240
  [docs/heartbeat.md](docs/heartbeat.md#heartbeat-metadata)). A back office can show tracking as **online** while the
1092
1241
  latest record is not older than that expected gap. A `tracking_stop` with reason `service_start_failed` means Android
1093
1242
  refused the tracking foreground service (a `start()` from the background, or a restart in the background); the app
1094
- has to call `start()` again while it is visible.
1243
+ has to call `start()` again while it is visible. A `tracking_stop` with reason `user_stopped` means the user stopped
1244
+ the app (Settings → Force stop, or the Task Manager); its `stopped_at` is the time of the stop, which can be well
1245
+ before `recorded_at`. Records from a phone that is offline for a long time arrive late; the `heartbeat` records'
1246
+ `device` and `battery` readings (standby bucket, "Restricted" battery setting, free disk, temperature) help explain
1247
+ gaps.
1095
1248
 
1096
1249
  ## Example app
1097
1250
 
1098
- [`example/`](example/) is a plain HTML/JS Capacitor app, with a button for every method and a live log of all 13
1251
+ [`example/`](example/) is a plain HTML/JS Capacitor app, with a button for every method and a live log of all 15
1099
1252
  events:
1100
1253
 
1101
1254
  ```bash
@@ -1184,7 +1337,7 @@ The plugin is tested end to end on an Android emulator (AVD), in GitHub Actions
1184
1337
 
1185
1338
  | Part | Where | What |
1186
1339
  |---|---|---|
1187
- | Plugin suite | [`e2e/plugin/`](e2e/plugin/) | 34 scenarios against the plugin's [example app](#example-app): lifecycle (foreground-service start crashes, kills, reboots, updates), heartbeat and power (stationary GPS off, Doze, clock changes, offline, server errors, `syncInterval`), permissions, providers and geofences. |
1340
+ | Plugin suite | [`e2e/plugin/`](e2e/plugin/) | 47 scenarios against the plugin's [example app](#example-app): lifecycle (foreground-service start crashes, kills, reboots, updates), heartbeat and power (stationary GPS off, Doze, clock changes, offline, server errors, `syncInterval`), permissions, providers and geofences, and the round-3 behavior (restores without background location, the upload job, user stop, data migration and backup, encrypted tokens, upload rules, token races). |
1188
1341
  | Field-force suite | [`examples/field-force/e2e/`](examples/field-force/e2e/) | 12 scenarios against the [field-force example](#field-force-example): auto start, 02:00 stop, live location, route and odometer, online/offline audit, and PremiseMonitor receiving every record natively (also after a kill and a reboot). |
1189
1342
  | Test kit | [`testing/e2e-kit/`](testing/e2e-kit/) | adb, WebView and debug-command helpers, a mock back office, fixtures and assertions, on Node 22 without runtime dependencies. |
1190
1343
  | Manual procedures | [docs/e2e-runbook.md](docs/e2e-runbook.md) | M-01 … M-08: HMS on a Huawei phone, phone makers' task killers, a real drive, a 12-hour battery measurement, a real 02:00 stop, a real Android 14 boot, 16 KB alignment, real overnight Doze. |
@@ -1206,10 +1359,13 @@ Node ignores it.)
1206
1359
  CI runs the emulator suites through `.github/scripts/run-e2e.sh` in `.github/workflows/e2e-android.yml`, only when
1207
1360
  started by hand (Actions tab or `gh workflow run`; pushes and pull requests start no run, to save Actions minutes):
1208
1361
  the plugin suite on API 34 (with smaller runs on API 29 and 35), P-P08 on an image without Google Play services, the
1209
- field-force suite on API 34, and the long scenarios (with the `include-long` input). The build workflow
1210
- `.github/workflows/ci.yml`, also started by hand only, type-checks the kit and runs its unit tests,
1211
- type-checks and dry-runs both suites, runs the field-force Node tests, and checks that the Google Play build of the
1212
- field-force app supports 16 KB memory pages (`.github/scripts/check-16kb.py`).
1362
+ field-force suite on API 34, and the long scenarios (with the `include-long` input).
1363
+ `.github/workflows/typescript.yml` runs on every push: it type-checks the kit and runs its unit tests, type-checks and
1364
+ dry-runs both suites, and runs the field-force Node tests. `.github/workflows/android.yml`, also started by hand only,
1365
+ runs the Android unit tests, builds the example apps and checks that the Google Play build of the field-force app
1366
+ supports 16 KB memory pages (`.github/scripts/check-16kb.py`). `.github/workflows/ios.yml`, started by hand only, runs
1367
+ XCTest, the UI tests and the shared scenarios P-S01 to P-S07 on iOS simulators; on a Mac the same steps run with
1368
+ `.github/scripts/ios/check.sh all` ([docs/e2e-runbook.md](docs/e2e-runbook.md), 6.11).
1213
1369
 
1214
1370
  The field-force suite, together with the field-force app and the kit, is written so that it can move into the Bricks
1215
1371
  app as its integration test. The contract behind the tests (scenario ids, debug commands, mock back office) is
@@ -1238,15 +1394,26 @@ npm ci && npm run build && npm test
1238
1394
  npm publish # prepare builds again; publishConfig.access makes it public
1239
1395
  ```
1240
1396
 
1241
- Each later release:
1242
-
1243
- 1. Run `npm version <patch|minor|major>`. It sets the version in `package.json` and `package-lock.json`, copies it to
1244
- `PLUGIN_VERSION` in `src/web/device.ts` (`npm test` checks this; `android/build.gradle` reads `package.json`
1245
- itself), and commits and tags. Give the `CHANGELOG.md` section that version and the date.
1246
- 2. Merge to `master` and create a GitHub release with a tag such as `v8.0.1`. The release starts the workflow (it can
1247
- also be started by hand from the Actions tab). The workflow builds, runs `npm test` and runs `npm stage publish`.
1248
- 3. In the package's **Staged Packages** tab on npmjs.com, approve the staged version (or run
1397
+ Each later release, with `scripts/release.sh` (bash; run it from the repository root on `master`):
1398
+
1399
+ 1. While you work, write the changes under `## [Unreleased]` in `CHANGELOG.md`.
1400
+ 2. `scripts/release.sh check <version>` runs every check and changes nothing: `master` is clean and equal to
1401
+ `origin/master`; the version is new and greater than the one on npm, and its major version is Capacitor's; the
1402
+ versions in `package.json`, `package-lock.json`, `src/web/device.ts` and
1403
+ `ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift` agree; `[Unreleased]` is not empty; git
1404
+ can sign; `npm run docgen` leaves the README unchanged; the changes since the last tag contain none of the
1405
+ private-name patterns (a file outside the repository, see the script); the steps of `typescript.yml` and
1406
+ `android.yml` pass on this computer, and on macOS those of `ios.yml` (elsewhere it warns that the iOS checks were
1407
+ skipped); and `npm pack` contains only the files listed in `package.json`.
1408
+ 3. `scripts/release.sh run <version>` runs the checks, renames `[Unreleased]` to `[<version>] - <date>`, runs
1409
+ `npm version <version> --no-git-tag-version` (it sets `package.json`, `package-lock.json`, `PLUGIN_VERSION` in
1410
+ `src/web/device.ts` and `PluginInfo.version` in Swift; `android/build.gradle` reads `package.json` itself), and makes a signed commit and a signed tag
1411
+ `v<version>`. After you type `yes`, it pushes both and creates the GitHub release with the CHANGELOG section as
1412
+ notes. The release starts the workflow (it can also be started by hand from the Actions tab), which builds, runs
1413
+ `npm test` and runs `npm stage publish`; the script waits for it.
1414
+ 4. In the package's **Staged Packages** tab on npmjs.com, approve the staged version (or run
1249
1415
  `npm stage approve <stage-id>`) with 2FA. Only then can apps install it.
1416
+ 5. `scripts/release.sh verify <version>` checks that npm serves it as `latest`, with a provenance statement.
1250
1417
 
1251
1418
  Versions published by the workflow carry an npm provenance statement (the repository is public), which links each
1252
1419
  version to the commit and workflow run that built it. The first version, published by hand, has none.
@@ -1313,6 +1480,8 @@ version to the commit and workflow run that built it. The first version, publish
1313
1480
  * [`addListener('enabledchange', ...)`](#addlistenerenabledchange-)
1314
1481
  * [`addListener('notificationaction', ...)`](#addlistenernotificationaction-)
1315
1482
  * [`addListener('authorization', ...)`](#addlistenerauthorization-)
1483
+ * [`addListener('locationerror', ...)`](#addlistenerlocationerror-)
1484
+ * [`addListener('storagefull', ...)`](#addlistenerstoragefull-)
1316
1485
  * [`removeAllListeners()`](#removealllisteners)
1317
1486
  * [Interfaces](#interfaces)
1318
1487
  * [Type Aliases](#type-aliases)
@@ -2114,6 +2283,38 @@ addListener(eventName: 'authorization', listenerFunc: (e: AuthorizationEvent) =>
2114
2283
  --------------------
2115
2284
 
2116
2285
 
2286
+ ### addListener('locationerror', ...)
2287
+
2288
+ ```typescript
2289
+ addListener(eventName: 'locationerror', listenerFunc: (e: LocationErrorEvent) => void) => Promise<PluginListenerHandle>
2290
+ ```
2291
+
2292
+ | Param | Type |
2293
+ | ------------------ | --------------------------------------------------------------------------------- |
2294
+ | **`eventName`** | <code>'locationerror'</code> |
2295
+ | **`listenerFunc`** | <code>(e: <a href="#locationerrorevent">LocationErrorEvent</a>) =&gt; void</code> |
2296
+
2297
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
2298
+
2299
+ --------------------
2300
+
2301
+
2302
+ ### addListener('storagefull', ...)
2303
+
2304
+ ```typescript
2305
+ addListener(eventName: 'storagefull', listenerFunc: (e: StorageFullEvent) => void) => Promise<PluginListenerHandle>
2306
+ ```
2307
+
2308
+ | Param | Type |
2309
+ | ------------------ | ----------------------------------------------------------------------------- |
2310
+ | **`eventName`** | <code>'storagefull'</code> |
2311
+ | **`listenerFunc`** | <code>(e: <a href="#storagefullevent">StorageFullEvent</a>) =&gt; void</code> |
2312
+
2313
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
2314
+
2315
+ --------------------
2316
+
2317
+
2117
2318
  ### removeAllListeners()
2118
2319
 
2119
2320
  ```typescript
@@ -2128,15 +2329,18 @@ removeAllListeners() => Promise<void>
2128
2329
 
2129
2330
  #### State
2130
2331
 
2131
- | Prop | Type | Description |
2132
- | ------------------ | ----------------------------------------------------------- | ----------------------------- |
2133
- | **`enabled`** | <code>boolean</code> | |
2134
- | **`trackingMode`** | <code><a href="#trackingmode">TrackingMode</a></code> | |
2135
- | **`isMoving`** | <code>boolean</code> | |
2136
- | **`odometer`** | <code>number</code> | |
2137
- | **`backend`** | <code><a href="#locationbackend">LocationBackend</a></code> | |
2138
- | **`lastRecordAt`** | <code>string \| null</code> | |
2139
- | **`config`** | <code><a href="#config">Config</a></code> | fully populated with defaults |
2332
+ | Prop | Type | Description |
2333
+ | -------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2334
+ | **`enabled`** | <code>boolean</code> | |
2335
+ | **`trackingMode`** | <code><a href="#trackingmode">TrackingMode</a></code> | |
2336
+ | **`isMoving`** | <code>boolean</code> | |
2337
+ | **`odometer`** | <code>number</code> | |
2338
+ | **`backend`** | <code><a href="#locationbackend">LocationBackend</a></code> | |
2339
+ | **`lastRecordAt`** | <code>string \| null</code> | |
2340
+ | **`lastStopReason`** | <code>string \| null</code> | reason of the last `tracking_stop` record (the `tracking_stop` reasons of <a href="#location">`Location.reason`</a>); null if none |
2341
+ | **`lastStopAt`** | <code>string \| null</code> | when tracking stopped for `lastStopReason` (ISO-8601 UTC ms); null if none |
2342
+ | **`config`** | <code><a href="#config">Config</a></code> | fully populated with defaults. On Android the `http.authorization` secrets (`accessToken`, `refreshToken`, the values of `refreshPayload` and `refreshHeaders`) are masked: `***`, or `***` plus the last 4 characters for values longer than 8 characters; a `refreshPayload` value that contains `{refreshToken}` is shown. Passing a masked value back to `setConfig` keeps the stored one. The web stub returns them as given. |
2343
+ | **`warnings`** | <code>string[]</code> | `ready()`, `setConfig()` and `reset()` only: what the given config contains that was accepted but is probably wrong (for example an `http://` upload URL). |
2140
2344
 
2141
2345
 
2142
2346
  #### Config
@@ -2199,32 +2403,34 @@ removeAllListeners() => Promise<void>
2199
2403
 
2200
2404
  #### HeartbeatConfig
2201
2405
 
2202
- | Prop | Type | Description | Default |
2203
- | ----------------- | -------------------- | -------------------------- | ----------------- |
2204
- | **`enabled`** | <code>boolean</code> | | <code>true</code> |
2205
- | **`minInterval`** | <code>number</code> | seconds, min 60 | <code>180</code> |
2206
- | **`maxInterval`** | <code>number</code> | seconds, &gt;= minInterval | <code>300</code> |
2406
+ | Prop | Type | Description | Default |
2407
+ | --------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
2408
+ | **`enabled`** | <code>boolean</code> | | <code>true</code> |
2409
+ | **`minInterval`** | <code>number</code> | seconds, min 60 | <code>180</code> |
2410
+ | **`maxInterval`** | <code>number</code> | seconds, &gt;= minInterval | <code>300</code> |
2411
+ | **`uploadKeepAwake`** | <code>number</code> | seconds, 0..60. After a heartbeat is queued, keep the phone awake until its upload has finished, or at most this long, so it reaches the server on time while tracking is on. 0 = do not keep the phone awake. | <code>10</code> |
2207
2412
 
2208
2413
 
2209
2414
  #### HttpConfig
2210
2415
 
2211
- | Prop | Type | Description | Default |
2212
- | ------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------- |
2213
- | **`url`** | <code>string \| null</code> | No url =&gt; nothing is uploaded (records stay queued). | |
2214
- | **`method`** | <code><a href="#httpmethod">HttpMethod</a></code> | | <code>'POST'</code> |
2215
- | **`headers`** | <code><a href="#record">Record</a>&lt;string, string&gt;</code> | | <code>{}</code> |
2216
- | **`params`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> | merged into the ROOT of every request body | <code>{}</code> |
2217
- | **`autoSync`** | <code>boolean</code> | | <code>true</code> |
2218
- | **`autoSyncThreshold`** | <code>number</code> | upload when queue &gt;= threshold; 0 = every record | <code>0</code> |
2219
- | **`syncInterval`** | <code>number</code> | s; upload normal records once the oldest queued one is this old; 0 = off | <code>0</code> |
2220
- | **`batchSync`** | <code>boolean</code> | | <code>false</code> |
2221
- | **`maxBatchSize`** | <code>number</code> | | <code>100</code> |
2222
- | **`disableAutoSyncOnCellular`** | <code>boolean</code> | | <code>false</code> |
2223
- | **`rootProperty`** | <code>string</code> | '.' = no wrapping | <code>'location'</code> |
2224
- | **`locationTemplate`** | <code>string \| null</code> | JSON text with `&lt;%= name %&gt;` placeholders | |
2225
- | **`geofenceTemplate`** | <code>string \| null</code> | used for event 'geofence'; falls back to locationTemplate | |
2226
- | **`timeout`** | <code>number</code> | ms | <code>60000</code> |
2227
- | **`authorization`** | <code><a href="#authorizationconfig">AuthorizationConfig</a> \| null</code> | | |
2416
+ | Prop | Type | Description | Default |
2417
+ | ------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- |
2418
+ | **`url`** | <code>string \| null</code> | No url =&gt; nothing is uploaded (records stay queued). | |
2419
+ | **`method`** | <code><a href="#httpmethod">HttpMethod</a></code> | | <code>'POST'</code> |
2420
+ | **`headers`** | <code><a href="#record">Record</a>&lt;string, string&gt;</code> | | <code>{}</code> |
2421
+ | **`params`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> | merged into the ROOT of every request body | <code>{}</code> |
2422
+ | **`autoSync`** | <code>boolean</code> | | <code>true</code> |
2423
+ | **`autoSyncThreshold`** | <code>number</code> | upload when queue &gt;= threshold; 0 = every record | <code>0</code> |
2424
+ | **`syncInterval`** | <code>number</code> | s; upload normal records once the oldest queued one is this old; 0 = off | <code>0</code> |
2425
+ | **`batchSync`** | <code>boolean</code> | | <code>false</code> |
2426
+ | **`maxBatchSize`** | <code>number</code> | | <code>100</code> |
2427
+ | **`disableAutoSyncOnCellular`** | <code>boolean</code> | | <code>false</code> |
2428
+ | **`rootProperty`** | <code>string</code> | '.' = no wrapping | <code>'location'</code> |
2429
+ | **`locationTemplate`** | <code>string \| null</code> | JSON text with `&lt;%= name %&gt;` placeholders | |
2430
+ | **`geofenceTemplate`** | <code>string \| null</code> | used for event 'geofence'; falls back to locationTemplate | |
2431
+ | **`timeout`** | <code>number</code> | ms | <code>60000</code> |
2432
+ | **`maxRetryAfter`** | <code>number</code> | seconds, &gt;= 0. The longest wait that a `Retry-After` header of a 429 or 503 answer can impose on automatic uploads. | <code>3600</code> |
2433
+ | **`authorization`** | <code><a href="#authorizationconfig">AuthorizationConfig</a> \| null</code> | | |
2228
2434
 
2229
2435
 
2230
2436
  #### AuthorizationConfig
@@ -2288,12 +2494,13 @@ Posted when Android refuses to restore tracking from the background (after a reb
2288
2494
  restart), typically on Android 14+ without "Allow all the time". A tap resumes the session
2289
2495
  (`tracking_start` reason `resume_notification`). Uses the tracking notification's small icon and color.
2290
2496
 
2291
- | Prop | Type | Default |
2292
- | ----------------- | -------------------- | ---------------------------------------------------------- |
2293
- | **`enabled`** | <code>boolean</code> | <code>false</code> |
2294
- | **`title`** | <code>string</code> | <code>app label</code> |
2295
- | **`text`** | <code>string</code> | <code>'Location tracking is paused. Tap to resume.'</code> |
2296
- | **`channelName`** | <code>string</code> | <code>'Paused location tracking'</code> |
2497
+ | Prop | Type | Description | Default |
2498
+ | ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
2499
+ | **`delay`** | <code>number</code> | iOS only (Android warns about an unknown key): seconds from the last activity to the check-in notification; raised to `heartbeat.maxInterval + 60` when lower. | <code>600</code> |
2500
+ | **`enabled`** | <code>boolean</code> | | <code>false</code> |
2501
+ | **`title`** | <code>string</code> | | <code>app label</code> |
2502
+ | **`text`** | <code>string</code> | | <code>'Location tracking is paused. Tap to resume.'</code> |
2503
+ | **`channelName`** | <code>string</code> | | <code>'Paused location tracking'</code> |
2297
2504
 
2298
2505
 
2299
2506
  #### GeofenceConfig
@@ -2331,27 +2538,30 @@ restart), typically on Android 14+ without "Allow all the time". A tap resumes t
2331
2538
 
2332
2539
  #### Location
2333
2540
 
2334
- | Prop | Type | Description |
2335
- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2336
- | **`uuid`** | <code>string</code> | |
2337
- | **`event`** | <code><a href="#recordevent">RecordEvent</a></code> | |
2338
- | **`timestamp`** | <code>string \| null</code> | fix time (ISO-8601 UTC ms); null if no location ever known |
2339
- | **`recorded_at`** | <code>string</code> | record creation time |
2340
- | **`sent_at`** | <code>string</code> | only present in HTTP bodies |
2341
- | **`elapsed_realtime_ms`** | <code>number</code> | |
2342
- | **`boot_count`** | <code>number</code> | -1 if unavailable |
2343
- | **`is_moving`** | <code>boolean</code> | |
2344
- | **`odometer`** | <code>number</code> | |
2345
- | **`mock`** | <code>boolean</code> | |
2346
- | **`coords`** | <code><a href="#coords">Coords</a> \| null</code> | |
2347
- | **`activity`** | <code>{ type: <a href="#activitytype">ActivityType</a>; confidence: number; }</code> | |
2348
- | **`battery`** | <code>{ level: number; is_charging: boolean; }</code> | level 0..1, -1 unknown |
2349
- | **`backend`** | <code><a href="#locationbackend">LocationBackend</a> \| null</code> | |
2350
- | **`extras`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> | |
2351
- | **`geofence`** | <code>{ identifier: string; action: <a href="#geofenceaction">GeofenceAction</a>; extras?: <a href="#record">Record</a>&lt;string, unknown&gt;; }</code> | event 'geofence' only |
2352
- | **`provider`** | <code><a href="#providerstate">ProviderState</a></code> | provider state when the record was created; the new state for event 'providerchange' |
2353
- | **`reason`** | <code>string</code> | tracking_start: start\|start_geofences\|boot\|restore\|package_replaced\|resume_notification; tracking_stop: stop\|stop_on_stationary\|stop_after_elapsed\|terminate\|permission_denied\|service_start_failed\|reboot\|package_replaced |
2354
- | **`heartbeat`** | <code><a href="#heartbeatmeta">HeartbeatMeta</a></code> | event 'heartbeat' only, optional: how the heartbeat is scheduled |
2541
+ | Prop | Type | Description |
2542
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2543
+ | **`uuid`** | <code>string</code> | |
2544
+ | **`event`** | <code><a href="#recordevent">RecordEvent</a></code> | |
2545
+ | **`timestamp`** | <code>string \| null</code> | fix time (ISO-8601 UTC ms); null if no location ever known |
2546
+ | **`recorded_at`** | <code>string</code> | record creation time |
2547
+ | **`sent_at`** | <code>string</code> | only present in HTTP bodies |
2548
+ | **`elapsed_realtime_ms`** | <code>number</code> | |
2549
+ | **`boot_count`** | <code>number</code> | -1 if unavailable |
2550
+ | **`is_moving`** | <code>boolean</code> | |
2551
+ | **`odometer`** | <code>number</code> | |
2552
+ | **`mock`** | <code>boolean</code> | |
2553
+ | **`coords`** | <code><a href="#coords">Coords</a> \| null</code> | |
2554
+ | **`activity`** | <code>{ type: <a href="#activitytype">ActivityType</a>; confidence: number; }</code> | |
2555
+ | **`battery`** | <code>{ level: number; is_charging: boolean; temperature?: number; health?: 'unknown' \| 'good' \| 'overheat' \| 'dead' \| 'over_voltage' \| 'cold' \| 'failure'; plugged?: 'none' \| 'ac' \| 'usb' \| 'wireless' \| 'dock' \| 'other'; }</code> | level 0..1, -1 unknown. Heartbeat records also carry `temperature` (°C), `health` and `plugged` when the device reports them. |
2556
+ | **`backend`** | <code><a href="#locationbackend">LocationBackend</a> \| null</code> | |
2557
+ | **`extras`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> | |
2558
+ | **`geofence`** | <code>{ identifier: string; action: <a href="#geofenceaction">GeofenceAction</a>; extras?: <a href="#record">Record</a>&lt;string, unknown&gt;; }</code> | event 'geofence' only |
2559
+ | **`provider`** | <code><a href="#providerstate">ProviderState</a></code> | provider state when the record was created; the new state for event 'providerchange' |
2560
+ | **`reason`** | <code>string</code> | tracking_start: start\|start_geofences\|boot\|restore\|package_replaced\|resume_notification; tracking_stop: stop\|stop_on_stationary\|stop_after_elapsed\|terminate\|permission_denied\|service_start_failed\|reboot\|package_replaced\|user_stopped |
2561
+ | **`stopped_at`** | <code>string</code> | event 'tracking_stop' with reason 'user_stopped' only: when the user stopped the app (ISO-8601 UTC ms) |
2562
+ | **`heartbeat`** | <code><a href="#heartbeatmeta">HeartbeatMeta</a></code> | event 'heartbeat' only, optional: how the heartbeat is scheduled |
2563
+ | **`network`** | <code><a href="#connectivitytype">ConnectivityType</a></code> | connection type when the record was created |
2564
+ | **`device`** | <code><a href="#devicereadings">DeviceReadings</a></code> | the device id on every record; more readings on 'heartbeat' and 'tracking_start' records (see <a href="#devicereadings">`DeviceReadings`</a>) |
2355
2565
 
2356
2566
 
2357
2567
  #### Coords
@@ -2385,14 +2595,36 @@ restart), typically on Android 14+ without "Allow all the time". A tap resumes t
2385
2595
 
2386
2596
  Scheduling metadata of a `heartbeat` record (snake_case = wire format).
2387
2597
 
2388
- | Prop | Type | Description |
2389
- | -------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
2390
- | **`strategy`** | <code>'exact' \| 'listener_with_backup' \| 'idle_paced'</code> | |
2391
- | **`min_interval`** | <code>number</code> | heartbeat.minInterval (s) when the heartbeat was created |
2392
- | **`max_interval`** | <code>number</code> | heartbeat.maxInterval (s) when the heartbeat was created |
2393
- | **`next_at`** | <code>string \| null</code> | when the next heartbeat is expected (ISO-8601 UTC ms); null if unknown |
2394
- | **`battery_exempt`** | <code>boolean</code> | the app is exempt from battery optimization |
2395
- | **`device_idle`** | <code>boolean</code> | the device was in deep Doze |
2598
+ | Prop | Type | Description |
2599
+ | -------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
2600
+ | **`strategy`** | <code>'exact' \| 'listener_with_backup' \| 'idle_paced' \| 'timer'</code> | |
2601
+ | **`min_interval`** | <code>number</code> | heartbeat.minInterval (s) when the heartbeat was created |
2602
+ | **`max_interval`** | <code>number</code> | heartbeat.maxInterval (s) when the heartbeat was created |
2603
+ | **`next_at`** | <code>string \| null</code> | when the next heartbeat is expected (ISO-8601 UTC ms); null if unknown |
2604
+ | **`battery_exempt`** | <code>boolean</code> | the app is exempt from battery optimization |
2605
+ | **`device_idle`** | <code>boolean</code> | the device was in deep Doze |
2606
+
2607
+
2608
+ #### DeviceReadings
2609
+
2610
+ The `device` object of records; a reading is present only when the device provides it.
2611
+ Every record: `device_id`.
2612
+ Heartbeat records: `background_restricted`, `standby_bucket`, `disk_free_bytes`, `thermal_status`.
2613
+ `tracking_start` records: `manufacturer`, `model`, `os_version`, `sdk_int`, `low_ram_device`, `ram_total_bytes`.
2614
+
2615
+ | Prop | Type | Description |
2616
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2617
+ | **`device_id`** | <code>string</code> | the device id: on Android, `Settings.Secure.ANDROID_ID`; other platforms will fill it with their own id. Absent in records queued by an older version. Declare "Device or other IDs" in Google Play's Data safety form. |
2618
+ | **`background_restricted`** | <code>boolean</code> | the user set the app's battery use to "Restricted" (Android 9+) |
2619
+ | **`standby_bucket`** | <code>'exempted' \| 'active' \| 'working_set' \| 'frequent' \| 'rare' \| 'restricted'</code> | App Standby bucket (Android 9+) |
2620
+ | **`disk_free_bytes`** | <code>number</code> | free space on the storage that holds the app's files |
2621
+ | **`thermal_status`** | <code>'none' \| 'unknown' \| 'light' \| 'moderate' \| 'severe' \| 'critical' \| 'emergency' \| 'shutdown'</code> | Android 10+ |
2622
+ | **`manufacturer`** | <code>string</code> | |
2623
+ | **`model`** | <code>string</code> | |
2624
+ | **`os_version`** | <code>string</code> | Android version, e.g. "14" |
2625
+ | **`sdk_int`** | <code>number</code> | |
2626
+ | **`low_ram_device`** | <code>boolean</code> | |
2627
+ | **`ram_total_bytes`** | <code>number</code> | |
2396
2628
 
2397
2629
 
2398
2630
  #### CurrentPositionOptions
@@ -2419,13 +2651,13 @@ Scheduling metadata of a `heartbeat` record (snake_case = wire format).
2419
2651
 
2420
2652
  #### InsertLocationInput
2421
2653
 
2422
- | Prop | Type |
2423
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2424
- | **`coords`** | <code><a href="#pick">Pick</a>&lt;<a href="#coords">Coords</a>, 'latitude' \| 'longitude'&gt; & <a href="#partial">Partial</a>&lt;<a href="#coords">Coords</a>&gt;</code> |
2425
- | **`timestamp`** | <code>string</code> |
2426
- | **`event`** | <code><a href="#recordevent">RecordEvent</a></code> |
2427
- | **`is_moving`** | <code>boolean</code> |
2428
- | **`extras`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> |
2654
+ | Prop | Type | Description |
2655
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
2656
+ | **`coords`** | <code><a href="#pick">Pick</a>&lt;<a href="#coords">Coords</a>, 'latitude' \| 'longitude' \| 'accuracy'&gt; & <a href="#partial">Partial</a>&lt;<a href="#coords">Coords</a>&gt;</code> | `accuracy` (m) is required and must be greater than 0; otherwise the call rejects with `INVALID_ARGUMENT`. |
2657
+ | **`timestamp`** | <code>string</code> | |
2658
+ | **`event`** | <code><a href="#recordevent">RecordEvent</a></code> | |
2659
+ | **`is_moving`** | <code>boolean</code> | |
2660
+ | **`extras`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> | |
2429
2661
 
2430
2662
 
2431
2663
  #### Geofence
@@ -2484,19 +2716,26 @@ the computed enclosing circle.
2484
2716
 
2485
2717
  #### DeviceInfo
2486
2718
 
2487
- | Prop | Type |
2488
- | ----------------------- | ----------------------------------------------------------- |
2489
- | **`platform`** | <code>'android' \| 'web'</code> |
2490
- | **`manufacturer`** | <code>string</code> |
2491
- | **`model`** | <code>string</code> |
2492
- | **`brand`** | <code>string</code> |
2493
- | **`osVersion`** | <code>string</code> |
2494
- | **`sdkInt`** | <code>number</code> |
2495
- | **`pluginVersion`** | <code>string</code> |
2496
- | **`gmsAvailable`** | <code>boolean</code> |
2497
- | **`hmsAvailable`** | <code>boolean</code> |
2498
- | **`backend`** | <code><a href="#locationbackend">LocationBackend</a></code> |
2499
- | **`packagedProviders`** | <code>string[]</code> |
2719
+ | Prop | Type | Description |
2720
+ | --------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
2721
+ | **`platform`** | <code>'android' \| 'web' \| 'ios'</code> | |
2722
+ | **`manufacturer`** | <code>string</code> | |
2723
+ | **`model`** | <code>string</code> | |
2724
+ | **`brand`** | <code>string</code> | |
2725
+ | **`osVersion`** | <code>string</code> | |
2726
+ | **`sdkInt`** | <code>number</code> | |
2727
+ | **`pluginVersion`** | <code>string</code> | |
2728
+ | **`gmsAvailable`** | <code>boolean</code> | |
2729
+ | **`hmsAvailable`** | <code>boolean</code> | |
2730
+ | **`backend`** | <code><a href="#locationbackend">LocationBackend</a></code> | |
2731
+ | **`packagedProviders`** | <code>string[]</code> | |
2732
+ | **`backendFallbackReason`** | <code>string \| null</code> | why a better location backend is not used (for example "gms: unavailable (status 2)"); null when none is better |
2733
+ | **`gmsStatus`** | <code>number \| null</code> | raw GoogleApiAvailability result (0 = success); null if GMS is not packaged or not checked yet |
2734
+ | **`hmsStatus`** | <code>number \| null</code> | raw HuaweiApiAvailability result (0 = success); null if HMS is not packaged or not checked yet |
2735
+ | **`hmsCoreOutdated`** | <code>boolean \| null</code> | true when HMS Core is older than 6.3.0.301; null if HMS is not packaged |
2736
+ | **`locationAvailableHint`** | <code>boolean \| null</code> | last LocationAvailability reported by GMS or HMS; only a hint (it can be wrong both ways); null if none |
2737
+ | **`cpuCores`** | <code>number</code> | number of CPU cores |
2738
+ | **`supportedAbis`** | <code>string[]</code> | CPU types (Build.SUPPORTED_ABIS), e.g. ["arm64-v8a"] |
2500
2739
 
2501
2740
 
2502
2741
  #### Sensors
@@ -2608,9 +2847,10 @@ the computed enclosing circle.
2608
2847
 
2609
2848
  #### EnabledChangeEvent
2610
2849
 
2611
- | Prop | Type |
2612
- | ------------- | -------------------- |
2613
- | **`enabled`** | <code>boolean</code> |
2850
+ | Prop | Type | Description |
2851
+ | ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2852
+ | **`enabled`** | <code>boolean</code> | |
2853
+ | **`reason`** | <code>string \| null</code> | why: a `tracking_start` reason when tracking was switched on (start, start_geofences, resume_notification), a `tracking_stop` reason when it was switched off; the same values as <a href="#location">`Location.reason`</a> |
2614
2854
 
2615
2855
 
2616
2856
  #### NotificationActionEvent
@@ -2630,6 +2870,29 @@ the computed enclosing circle.
2630
2870
  | **`response`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> |
2631
2871
 
2632
2872
 
2873
+ #### LocationErrorEvent
2874
+
2875
+ The location request failed (no permission, Play services or HMS Core refused it, no usable provider).
2876
+
2877
+ | Prop | Type | Description |
2878
+ | --------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
2879
+ | **`code`** | <code><a href="#errorcode">ErrorCode</a></code> | |
2880
+ | **`message`** | <code>string</code> | |
2881
+ | **`backend`** | <code><a href="#locationbackend">LocationBackend</a></code> | |
2882
+ | **`retryInMs`** | <code>number \| null</code> | the plugin requests location updates again after this many ms; null if it does not |
2883
+
2884
+
2885
+ #### StorageFullEvent
2886
+
2887
+ A record could not be stored because the device storage is full.
2888
+
2889
+ | Prop | Type |
2890
+ | ------------------- | --------------------------------------------------- |
2891
+ | **`uuid`** | <code>string</code> |
2892
+ | **`event`** | <code><a href="#recordevent">RecordEvent</a></code> |
2893
+ | **`diskFreeBytes`** | <code>number \| null</code> |
2894
+
2895
+
2633
2896
  ### Type Aliases
2634
2897
 
2635
2898
 
@@ -2640,7 +2903,7 @@ the computed enclosing circle.
2640
2903
 
2641
2904
  #### LocationBackend
2642
2905
 
2643
- <code>'gms' | 'hms' | 'android' | 'web'</code>
2906
+ <code>'gms' | 'hms' | 'android' | 'web' | 'ios'</code>
2644
2907
 
2645
2908
 
2646
2909
  #### DesiredAccuracy
@@ -2657,9 +2920,7 @@ the computed enclosing circle.
2657
2920
 
2658
2921
  Construct a type with a set of properties K of type T
2659
2922
 
2660
- <code>{
2661
- [P in K]: T;
2662
- }</code>
2923
+ <code>{ [P in K]: T; }</code>
2663
2924
 
2664
2925
 
2665
2926
  #### NotificationPriority
@@ -2692,6 +2953,11 @@ Construct a type with a set of properties K of type T
2692
2953
  <code>'ENTER' | 'EXIT' | 'DWELL'</code>
2693
2954
 
2694
2955
 
2956
+ #### ConnectivityType
2957
+
2958
+ <code>'wifi' | 'cellular' | 'ethernet' | 'other' | 'none'</code>
2959
+
2960
+
2695
2961
  #### WatchPositionCallback
2696
2962
 
2697
2963
  <code>(location: <a href="#location">Location</a> | null, error?: { code: <a href="#errorcode">ErrorCode</a>; message: string; }): void</code>
@@ -2713,23 +2979,19 @@ Rejection `code` of every failed promise.
2713
2979
 
2714
2980
  From T, pick a set of properties whose keys are in the union K
2715
2981
 
2716
- <code>{
2717
- [P in K]: T[P];
2718
- }</code>
2982
+ <code>{ [P in K]: T[P]; }</code>
2719
2983
 
2720
2984
 
2721
2985
  #### Partial
2722
2986
 
2723
2987
  Make all properties in T optional
2724
2988
 
2725
- <code>{
2726
- [P in keyof T]?: T[P];
2727
- }</code>
2989
+ <code>{ [P in keyof T]?: T[P]; }</code>
2728
2990
 
2729
2991
 
2730
2992
  #### HeartbeatStrategy
2731
2993
 
2732
- <code>'exact' | 'listener_with_backup' | 'idle_paced' | 'disabled'</code>
2994
+ <code>'exact' | 'listener_with_backup' | 'idle_paced' | 'timer' | 'disabled'</code>
2733
2995
 
2734
2996
 
2735
2997
  #### PermissionState
@@ -2748,13 +3010,80 @@ Make all properties in T optional
2748
3010
 
2749
3011
  <code>T extends U ? never : T</code>
2750
3012
 
2751
-
2752
- #### ConnectivityType
2753
-
2754
- <code>'wifi' | 'cellular' | 'ethernet' | 'other' | 'none'</code>
2755
-
2756
3013
  </docgen-api>
2757
3014
 
2758
3015
  ## License
2759
3016
 
2760
3017
  MIT, © 2026 Bricks Soft. See [LICENSE](LICENSE).
3018
+
3019
+ ## iOS setup
3020
+
3021
+ The iOS implementation supports native route tracking, motion detection, heartbeat audit records, restore, check-in
3022
+ notifications, configuration, SQLite queueing, HTTP sync, permissions, current positions and watches. Geofencing needs
3023
+ iOS 17; below it, `startGeofences` and the geofence methods reject with `UNAVAILABLE`.
3024
+
3025
+ The deployment target is iOS 15. Install `@capacitor/ios` 8 and run `npx cap sync ios`. Both SPM and CocoaPods are supported.
3026
+ In the host app's Info.plist, add:
3027
+
3028
+ ```xml
3029
+ <key>UIBackgroundModes</key><array><string>location</string><string>fetch</string></array>
3030
+ <key>BGTaskSchedulerPermittedIdentifiers</key><array><string>com.brickssoft.locationtracking.refresh</string></array>
3031
+ <key>NSLocationWhenInUseUsageDescription</key><string>Explain your foreground tracking purpose.</string>
3032
+ <key>NSLocationAlwaysAndWhenInUseUsageDescription</key><string>Explain your optional background tracking purpose.</string>
3033
+ <key>NSMotionUsageDescription</key><string>Explain how motion selects the tracking profile.</string>
3034
+ ```
3035
+
3036
+ Call the native bootstrap before `application(_:didFinishLaunchingWithOptions:)` returns, so the background refresh
3037
+ handler is registered during launch:
3038
+
3039
+ ```swift
3040
+ import LocationTrackingPlugin
3041
+
3042
+ LocationTrackingNative.bootstrap()
3043
+ ```
3044
+
3045
+ For apps that track only while they are in use, request `{ permissions: ['location'] }`. Always is optional, and the
3046
+ Always purpose string is still required because the library references that API. When the user grants approximate
3047
+ location, `ProviderState.accuracy` is `'approximate'` and a `providerchange` event follows; stored geofences stay inactive
3048
+ and a `PERMISSION_DENIED` `locationerror` says why. Explain it in the app and call `openAppSettings()` so the user can
3049
+ turn on Precise Location, which stays on.
3050
+
3051
+ Queued records and configuration use Application Support with backup exclusion and protection until first unlock.
3052
+ Authorization is held in Keychain with the same unlock requirement. HTTP uses an ephemeral session, refuses redirects,
3053
+ and treats every 2xx as success; keep your upload endpoint on HTTPS. A refresh task is best effort and does not keep
3054
+ the app continuously running. `stop()` leaves position watches active; clear each watch explicitly.
3055
+
3056
+ While moving, the route uses the configured accuracy. While stationary, it keeps continuous 100 m updates so that
3057
+ movement can resume tracking without Motion permission. Core Motion confidence is 33, 66 or 100; the default confidence
3058
+ threshold accepts only high-confidence samples. Location update intervals are controlled by iOS.
3059
+
3060
+ Heartbeats use an in-process timer and are not backfilled after a suspension or kill. When In Use tracking must start
3061
+ in the foreground; enabled intent restores when the app next becomes active. Always authorization adds significant-change
3062
+ monitoring as a relaunch trigger. iOS does not relaunch a force-quit app for location events. If Location Services are
3063
+ switched off for the whole device, enabled tracking keeps its intent and original deadline and resumes when they are
3064
+ switched back on; a revoked app permission still stops it with `permission_denied`.
3065
+
3066
+ Enable `notification.resume` and request `notifications` permission for a pending check-in after tracking goes quiet.
3067
+ Its iOS `delay` defaults to 600 seconds and is raised to `heartbeat.maxInterval + 60` when lower. Fixes and records move
3068
+ one pending notification, at most once a minute. Stop and session expiry cancel it. A tap opens the app and restores the
3069
+ original session deadline. The plugin leaves Capacitor's notification-center delegate in place.
3070
+
3071
+ Geofences use one [`CLMonitor`](https://developer.apple.com/documentation/corelocation/clmonitor), opened again under the
3072
+ same name at every launch, and are registered while tracking runs. A polygon registers its enclosing circle and is
3073
+ hit-tested against fixes while the device is inside that circle. DWELL is synthesized from the time inside, so it fires
3074
+ only while the app runs. With Always authorization on iOS 18 and later, the plugin holds a
3075
+ [`CLServiceSession`](https://developer.apple.com/documentation/corelocation/clservicesession) so events also arrive when
3076
+ the app is not in use; with When In Use they arrive only while it runs. While location accuracy is approximate, stored
3077
+ geofences stay inactive and a `locationerror` with `PERMISSION_DENIED` says so.
3078
+
3079
+ `stopOnTerminate` handles the best-effort termination callback. An OS kill without that callback preserves enabled intent,
3080
+ even when `stopOnTerminate` is true. `startOnBoot` is ignored. Deadlines use the continuous elapsed clock within the process.
3081
+ On restore, matching saved wall/elapsed checkpoints preserve elapsed timing; a detected reboot or clock discontinuity uses
3082
+ the original wall-clock start. A wall-clock change while the process is absent cannot be distinguished reliably from a
3083
+ reboot on current iOS, which [no longer exposes a boot-session identifier](https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-18-release-notes).
3084
+ Fixes are ordered by the elapsed clock at delivery, so a wall-clock correction does not make new fixes look stale.
3085
+
3086
+ `example/ios` is the example host. Run `npm run copy-vendor` and `npx cap sync ios` from `example/` before building.
3087
+ The iOS workflow installs the npm tarball into a fresh app through each package manager and archives both unsigned and ad-hoc signed Release
3088
+ IPAs. The ad-hoc identity is `-`, with no provisioning profile; it does not provide Apple device authorization. Installing it on an iPhone still requires an Apple signing certificate/private key, a matching provisioning profile
3089
+ and entitlements for its bundle identifier, or a device-cloud service that re-signs uploaded apps.