@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
@@ -139,7 +139,17 @@ data class HeartbeatStatus(
139
139
 
140
140
  // ---- device
141
141
 
142
- /** JS `DeviceInfo`. */
142
+ /**
143
+ * JS `DeviceInfo`.
144
+ *
145
+ * @property backendFallbackReason why a better location backend is not used (recommendation 15), or null.
146
+ * @property gmsStatus `GoogleApiAvailability` result code (0 = SUCCESS), null if GMS is not packaged or not checked.
147
+ * @property hmsStatus `HuaweiApiAvailability` result code (0 = SUCCESS), null if HMS is not packaged or not checked.
148
+ * @property hmsCoreOutdated true when HMS Core is older than 6.3.0.301; null if HMS is not packaged.
149
+ * @property locationAvailableHint the last `LocationAvailability` of GMS or HMS; a hint, null if none was reported.
150
+ * @property cpuCores `Runtime.availableProcessors()`.
151
+ * @property supportedAbis `Build.SUPPORTED_ABIS`.
152
+ */
143
153
  data class DeviceInfo(
144
154
  val platform: String = "android",
145
155
  val manufacturer: String,
@@ -152,8 +162,41 @@ data class DeviceInfo(
152
162
  val hmsAvailable: Boolean,
153
163
  val backend: ProviderKind,
154
164
  val packagedProviders: List<String>,
165
+ val backendFallbackReason: String? = null,
166
+ val gmsStatus: Int? = null,
167
+ val hmsStatus: Int? = null,
168
+ val hmsCoreOutdated: Boolean? = null,
169
+ val locationAvailableHint: Boolean? = null,
170
+ val cpuCores: Int = 0,
171
+ val supportedAbis: List<String> = emptyList(),
155
172
  )
156
173
 
174
+ /**
175
+ * The `device` object of records (see `DeviceDetails`); a null field is not written. Every record: [deviceId].
176
+ * Heartbeat: [backgroundRestricted], [standbyBucket], [diskFreeBytes], [thermalStatus]. `tracking_start`:
177
+ * [manufacturer], [model], [osVersion], [sdkInt], [lowRamDevice], [ramTotalBytes].
178
+ *
179
+ * @property deviceId the device id: `Settings.Secure.ANDROID_ID` on Android; null in records queued by an older version.
180
+ */
181
+ data class DeviceSnapshot(
182
+ val backgroundRestricted: Boolean? = null,
183
+ val standbyBucket: String? = null,
184
+ val diskFreeBytes: Long? = null,
185
+ val thermalStatus: String? = null,
186
+ val manufacturer: String? = null,
187
+ val model: String? = null,
188
+ val osVersion: String? = null,
189
+ val sdkInt: Int? = null,
190
+ val deviceId: String? = null,
191
+ val lowRamDevice: Boolean? = null,
192
+ val ramTotalBytes: Long? = null,
193
+ ) {
194
+ val isEmpty: Boolean get() = this == DeviceSnapshot()
195
+ }
196
+
197
+ /** Battery readings added to the `battery` object of heartbeat records: °C, health and power source. */
198
+ data class BatteryDetails(val temperature: Float?, val health: String?, val plugged: String?)
199
+
157
200
  /** JS `Sensors`. */
158
201
  data class Sensors(
159
202
  val accelerometer: Boolean,
@@ -118,13 +118,18 @@ object RecordJson {
118
118
  "activity",
119
119
  JSONObject().put("type", record.activity.type.wire).put("confidence", record.activity.confidence),
120
120
  )
121
- json.put(
122
- "battery",
123
- JSONObject()
124
- .put("level", JsonUtil.floatOrNull(record.battery.level))
125
- .put("is_charging", record.battery.isCharging),
126
- )
121
+ val battery = JSONObject()
122
+ .put("level", JsonUtil.floatOrNull(record.battery.level))
123
+ .put("is_charging", record.battery.isCharging)
124
+ record.batteryDetails?.let { details ->
125
+ details.temperature?.let { battery.put("temperature", JsonUtil.floatOrNull(it)) }
126
+ details.health?.let { battery.put("health", it) }
127
+ details.plugged?.let { battery.put("plugged", it) }
128
+ }
129
+ json.put("battery", battery)
127
130
  json.put("backend", record.backend?.wire ?: JSONObject.NULL)
131
+ record.network?.let { json.put("network", it.wire) }
132
+ record.device?.takeUnless { it.isEmpty }?.let { json.put("device", deviceToJson(it)) }
128
133
  if (record.extras != null) {
129
134
  val extras = JsonUtil.parseObject(record.extras)
130
135
  if (extras != null) json.put("extras", extras) else Logger.w(TAG, "dropping invalid extras of ${record.uuid}")
@@ -133,9 +138,42 @@ object RecordJson {
133
138
  record.provider?.let { json.put("provider", ProviderStateJson.toJson(it)) }
134
139
  record.reason?.let { json.put("reason", it) }
135
140
  record.heartbeat?.let { json.put("heartbeat", heartbeatMetaToJson(it)) }
141
+ record.stoppedAt?.let { json.put("stopped_at", Iso8601.format(it)) }
142
+ return json
143
+ }
144
+
145
+ /** The `device` object of a record: only the readings that are set. */
146
+ fun deviceToJson(d: DeviceSnapshot): JSONObject {
147
+ val json = JSONObject()
148
+ d.deviceId?.let { json.put("device_id", it) }
149
+ d.backgroundRestricted?.let { json.put("background_restricted", it) }
150
+ d.standbyBucket?.let { json.put("standby_bucket", it) }
151
+ d.diskFreeBytes?.let { json.put("disk_free_bytes", it) }
152
+ d.thermalStatus?.let { json.put("thermal_status", it) }
153
+ d.manufacturer?.let { json.put("manufacturer", it) }
154
+ d.model?.let { json.put("model", it) }
155
+ d.osVersion?.let { json.put("os_version", it) }
156
+ d.sdkInt?.let { json.put("sdk_int", it) }
157
+ d.lowRamDevice?.let { json.put("low_ram_device", it) }
158
+ d.ramTotalBytes?.let { json.put("ram_total_bytes", it) }
136
159
  return json
137
160
  }
138
161
 
162
+ private fun deviceFromJson(json: JSONObject): DeviceSnapshot = DeviceSnapshot(
163
+ backgroundRestricted = JsonUtil.optBoolean(json, "background_restricted"),
164
+ standbyBucket = JsonUtil.optString(json, "standby_bucket"),
165
+ diskFreeBytes = JsonUtil.optLong(json, "disk_free_bytes"),
166
+ thermalStatus = JsonUtil.optString(json, "thermal_status"),
167
+ manufacturer = JsonUtil.optString(json, "manufacturer"),
168
+ model = JsonUtil.optString(json, "model"),
169
+ osVersion = JsonUtil.optString(json, "os_version"),
170
+ sdkInt = JsonUtil.optInt(json, "sdk_int"),
171
+ // `android_id`: the name before the rename, in tracking_start records queued by earlier round-3 builds
172
+ deviceId = JsonUtil.optString(json, "device_id") ?: JsonUtil.optString(json, "android_id"),
173
+ lowRamDevice = JsonUtil.optBoolean(json, "low_ram_device"),
174
+ ramTotalBytes = JsonUtil.optLong(json, "ram_total_bytes"),
175
+ )
176
+
139
177
  /** The `heartbeat` object of a heartbeat record (snake_case keys, `next_at` ISO-8601 or null). */
140
178
  fun heartbeatMetaToJson(meta: HeartbeatMeta): JSONObject = JSONObject()
141
179
  .put("strategy", meta.strategy.wire)
@@ -203,12 +241,20 @@ object RecordJson {
203
241
  confidence = JsonUtil.optInt(it, "confidence") ?: 0,
204
242
  )
205
243
  } ?: ActivitySample.UNKNOWN
206
- val battery = json.optJSONObject("battery")?.let {
244
+ val batteryJson = json.optJSONObject("battery")
245
+ val battery = batteryJson?.let {
207
246
  BatterySnapshot(
208
247
  level = JsonUtil.optFloat(it, "level") ?: -1f,
209
248
  isCharging = JsonUtil.optBoolean(it, "is_charging") ?: false,
210
249
  )
211
250
  } ?: BatterySnapshot.UNKNOWN
251
+ val batteryDetails = batteryJson?.let {
252
+ BatteryDetails(
253
+ temperature = JsonUtil.optFloat(it, "temperature"),
254
+ health = JsonUtil.optString(it, "health"),
255
+ plugged = JsonUtil.optString(it, "plugged"),
256
+ )
257
+ }?.takeUnless { it.temperature == null && it.health == null && it.plugged == null }
212
258
  return Record(
213
259
  uuid = uuid,
214
260
  event = event,
@@ -226,6 +272,10 @@ object RecordJson {
226
272
  provider = json.optJSONObject("provider")?.let { ProviderStateJson.fromJson(it) },
227
273
  reason = JsonUtil.optString(json, "reason"),
228
274
  heartbeat = json.optJSONObject("heartbeat")?.let { heartbeatMetaFromJson(it) },
275
+ stoppedAt = Iso8601.parse(JsonUtil.optString(json, "stopped_at")),
276
+ network = ConnectivityType.fromWire(JsonUtil.optString(json, "network")),
277
+ device = json.optJSONObject("device")?.let { deviceFromJson(it) },
278
+ batteryDetails = batteryDetails,
229
279
  )
230
280
  }
231
281
 
@@ -426,6 +476,13 @@ object DeviceInfoJson {
426
476
  .put("hmsAvailable", d.hmsAvailable)
427
477
  .put("backend", d.backend.wire)
428
478
  .put("packagedProviders", providers)
479
+ .put("backendFallbackReason", JsonUtil.orNull(d.backendFallbackReason))
480
+ .put("gmsStatus", JsonUtil.orNull(d.gmsStatus))
481
+ .put("hmsStatus", JsonUtil.orNull(d.hmsStatus))
482
+ .put("hmsCoreOutdated", JsonUtil.orNull(d.hmsCoreOutdated))
483
+ .put("locationAvailableHint", JsonUtil.orNull(d.locationAvailableHint))
484
+ .put("cpuCores", d.cpuCores)
485
+ .put("supportedAbis", JSONArray().also { abis -> d.supportedAbis.forEach { abis.put(it) } })
429
486
  }
430
487
  }
431
488
 
@@ -497,6 +554,8 @@ object EventJson {
497
554
  const val ENABLEDCHANGE = "enabledchange"
498
555
  const val NOTIFICATIONACTION = "notificationaction"
499
556
  const val AUTHORIZATION = "authorization"
557
+ const val LOCATIONERROR = "locationerror"
558
+ const val STORAGEFULL = "storagefull"
500
559
 
501
560
  fun name(event: TrackingEvent): String = when (event) {
502
561
  is TrackingEvent.Location -> LOCATION
@@ -512,6 +571,8 @@ object EventJson {
512
571
  is TrackingEvent.EnabledChange -> ENABLEDCHANGE
513
572
  is TrackingEvent.NotificationAction -> NOTIFICATIONACTION
514
573
  is TrackingEvent.Authorization -> AUTHORIZATION
574
+ is TrackingEvent.LocationError -> LOCATIONERROR
575
+ is TrackingEvent.StorageFull -> STORAGEFULL
515
576
  }
516
577
 
517
578
  fun payload(event: TrackingEvent): JSONObject = when (event) {
@@ -538,7 +599,8 @@ object EventJson {
538
599
  is TrackingEvent.Http -> HttpResultJson.toJson(event.result)
539
600
  is TrackingEvent.ConnectivityChange -> ConnectivityJson.toJson(event.connectivity)
540
601
  is TrackingEvent.PowerSaveChange -> JSONObject().put("isPowerSaveMode", event.isPowerSaveMode)
541
- is TrackingEvent.EnabledChange -> JSONObject().put("enabled", event.enabled)
602
+ is TrackingEvent.EnabledChange ->
603
+ JSONObject().put("enabled", event.enabled).put("reason", JsonUtil.orNull(event.reason))
542
604
  is TrackingEvent.NotificationAction -> JSONObject().put("id", event.id)
543
605
  is TrackingEvent.Authorization -> {
544
606
  val json = JSONObject().put("success", event.success).put("status", event.status)
@@ -546,5 +608,14 @@ object EventJson {
546
608
  JsonUtil.parseObject(event.responseJson)?.let { json.put("response", it) }
547
609
  json
548
610
  }
611
+ is TrackingEvent.LocationError -> JSONObject()
612
+ .put("code", event.code.name)
613
+ .put("message", event.message)
614
+ .put("backend", event.backend.wire)
615
+ .put("retryInMs", JsonUtil.orNull(event.retryInMs))
616
+ is TrackingEvent.StorageFull -> JSONObject()
617
+ .put("uuid", event.uuid)
618
+ .put("event", event.event.wire)
619
+ .put("diskFreeBytes", JsonUtil.orNull(event.diskFreeBytes))
549
620
  }
550
621
  }
@@ -85,6 +85,11 @@ data class GeofenceHit(val identifier: String, val action: GeofenceAction, val e
85
85
  * @property provider event `providerchange` only.
86
86
  * @property reason `tracking_start` / `tracking_stop` only.
87
87
  * @property heartbeat `heartbeat` only, and optional (round 2): the scheduling metadata of the heartbeat.
88
+ * @property stoppedAt `tracking_stop` only, when the stop happened before the record could be written (reason
89
+ * `user_stopped`: the time the user stopped the app), epoch ms.
90
+ * @property network the connection type when the record was created (every record; null if unknown).
91
+ * @property device device readings (heartbeat and `tracking_start` records only).
92
+ * @property batteryDetails battery temperature, health and power source (heartbeat records only).
88
93
  */
89
94
  data class Record(
90
95
  val uuid: String,
@@ -103,4 +108,8 @@ data class Record(
103
108
  val provider: ProviderState? = null,
104
109
  val reason: String? = null,
105
110
  val heartbeat: HeartbeatMeta? = null,
111
+ val stoppedAt: Long? = null,
112
+ val network: ConnectivityType? = null,
113
+ val device: DeviceSnapshot? = null,
114
+ val batteryDetails: BatteryDetails? = null,
106
115
  )
@@ -26,13 +26,23 @@ data class TrackedLocation(
26
26
  val isMock: Boolean = false,
27
27
  ) {
28
28
  companion object {
29
- /** Converts an Android [android.location.Location], keeping only the fields it actually has. */
29
+ /**
30
+ * The accuracy of a fix that has none (`Location.hasAccuracy()` false; some platform providers): 1,000 km,
31
+ * so the accuracy filters (`trackingAccuracyThreshold`, default 100 m) reject it. Before, it was stored as
32
+ * 0 m, which passes every accuracy filter (recommendation 12).
33
+ */
34
+ const val UNKNOWN_ACCURACY_M = 1_000_000f
35
+
36
+ /**
37
+ * Converts an Android [android.location.Location], keeping only the fields it actually has. A missing
38
+ * horizontal accuracy becomes [UNKNOWN_ACCURACY_M].
39
+ */
30
40
  fun from(l: android.location.Location): TrackedLocation {
31
41
  val api26 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
32
42
  return TrackedLocation(
33
43
  latitude = l.latitude,
34
44
  longitude = l.longitude,
35
- accuracy = l.accuracy,
45
+ accuracy = if (l.hasAccuracy()) l.accuracy else UNKNOWN_ACCURACY_M,
36
46
  altitude = if (l.hasAltitude()) l.altitude else null,
37
47
  altitudeAccuracy = if (api26 && l.hasVerticalAccuracy()) l.verticalAccuracyMeters else null,
38
48
  speed = if (l.hasSpeed()) l.speed else null,
@@ -33,7 +33,8 @@ import kotlin.math.min
33
33
  * `maximumAgeMs > 0` it returns the freshest known fix (runtime or backend last location) if it is young
34
34
  * enough. Otherwise it samples a temporary backend request for up to `samples` fixes, stops early on a fix of
35
35
  * [ACCURATE_ENOUGH_M] or better, and keeps the most accurate fix. The timeout bounds the whole call; at the
36
- * timeout the best fix so far is returned, or TIMEOUT is thrown if there is none.
36
+ * timeout the best fix so far is returned, or TIMEOUT is thrown if there is none. If the backend refuses the request
37
+ * (also later, through `LocationListener.onError`), the call rejects at once with that error.
37
38
  * - [watchPosition] registers one backend listener per watch id. Each fix becomes a `watch_position` record,
38
39
  * optionally persisted through [recordSink], and is delivered in order from [scope] (see [PositionWatch]).
39
40
  *
@@ -162,7 +163,14 @@ class DefaultPositionService(
162
163
  if (timeoutMs <= 0) return null
163
164
  val collector = SampleCollector(o.samples)
164
165
  val fixes = Channel<TrackedLocation>(Channel.UNLIMITED)
165
- val listener = LocationListener { batch -> batch.forEach { fixes.trySend(it) } }
166
+ val listener = object : LocationListener {
167
+ override fun onLocations(locations: List<TrackedLocation>) = locations.forEach { fixes.trySend(it) }
168
+
169
+ // The request failed (recommendation 3c): reject at once with that error instead of TIMEOUT.
170
+ override fun onError(error: TrackingException) {
171
+ fixes.close(error)
172
+ }
173
+ }
166
174
  val spec = LocationRequestSpec(o.desiredAccuracy, SAMPLE_INTERVAL_MS, SAMPLE_FASTEST_INTERVAL_MS, 0f)
167
175
  val requestedAt = clock.elapsedRealtime()
168
176
  try {
@@ -38,7 +38,11 @@ internal class PositionWatch(
38
38
  /** Guards [started], [stopped], [registered] and every [callback] invocation. */
39
39
  private val lock = Any()
40
40
  private val fixes = Channel<TrackedLocation>(BUFFER, BufferOverflow.DROP_OLDEST)
41
- private val listener = LocationListener { batch -> batch.forEach { fixes.trySend(it) } }
41
+ private val listener = object : LocationListener {
42
+ override fun onLocations(locations: List<TrackedLocation>) = locations.forEach { fixes.trySend(it) }
43
+
44
+ override fun onError(error: TrackingException) = onRequestFailed(error)
45
+ }
42
46
 
43
47
  @Volatile
44
48
  private var stopped = false
@@ -69,6 +73,25 @@ internal class PositionWatch(
69
73
  }
70
74
  }
71
75
 
76
+ /**
77
+ * The backend refused the request after [start] returned (recommendation 3): [callback] gets the error once and the
78
+ * watch ends (the backend no longer has the listener).
79
+ */
80
+ private fun onRequestFailed(error: TrackingException) {
81
+ synchronized(lock) {
82
+ if (stopped) return
83
+ try {
84
+ callback(null, error)
85
+ } catch (e: Exception) {
86
+ Logger.e(DefaultPositionService.TAG, "watch $id callback failed", e)
87
+ }
88
+ stopped = true
89
+ registered = false
90
+ }
91
+ fixes.close()
92
+ Logger.w(DefaultPositionService.TAG, "watch $id ended: ${error.code} ${error.message}")
93
+ }
94
+
72
95
  /** Removes the backend listener and ends deliveries. */
73
96
  fun stop() {
74
97
  val wasRegistered = synchronized(lock) {
@@ -6,8 +6,11 @@ import com.brickssoft.locationtracking.core.ErrorCode
6
6
  import com.brickssoft.locationtracking.core.Iso8601
7
7
  import com.brickssoft.locationtracking.core.Logger
8
8
  import com.brickssoft.locationtracking.core.TrackingException
9
+ import com.brickssoft.locationtracking.device.DeviceDetails
9
10
  import com.brickssoft.locationtracking.device.DeviceMonitor
10
11
  import com.brickssoft.locationtracking.model.BatterySnapshot
12
+ import com.brickssoft.locationtracking.model.ConnectivityType
13
+ import com.brickssoft.locationtracking.model.DeviceSnapshot
11
14
  import com.brickssoft.locationtracking.model.GeofenceHit
12
15
  import com.brickssoft.locationtracking.model.JsonUtil
13
16
  import com.brickssoft.locationtracking.model.ProviderKind
@@ -20,16 +23,19 @@ import org.json.JSONObject
20
23
  import java.util.UUID
21
24
 
22
25
  /**
23
- * Builds records: uuid v4, clock and boot metadata, runtime state (isMoving, odometer, activity), battery and
24
- * the current provider state (unless the caller passes one, as `providerchange` does) from [device], backend from
25
- * [providers], and `extras` = `persistence.extras` merged with the per-call extras (per-call keys win; null when
26
- * both are empty).
26
+ * Builds records: uuid v4, clock and boot metadata, runtime state (isMoving, odometer, activity), battery, the
27
+ * connection type and the current provider state (unless the caller passes one, as `providerchange` does) from
28
+ * [device], backend from [providers], and `extras` = `persistence.extras` merged with the per-call extras (per-call keys
29
+ * win; null when both are empty). Every record gets `device.device_id` from [details]; heartbeat records also get its
30
+ * device and battery readings, and `tracking_start` records the phone's model, Android version and memory.
27
31
  */
28
32
  class DefaultRecordFactory(
29
33
  private val configStore: ConfigStore,
30
34
  private val device: DeviceMonitor,
31
35
  private val providers: ProviderFactory,
32
36
  private val clock: Clock,
37
+ /** The device id of every record, and readings for heartbeat and `tracking_start` records (parts d1 to d7). */
38
+ private val details: DeviceDetails = DeviceDetails.NONE,
33
39
  ) : RecordFactory {
34
40
  override fun create(
35
41
  event: RecordEvent,
@@ -56,16 +62,30 @@ class DefaultRecordFactory(
56
62
  geofence = geofence,
57
63
  provider = provider ?: providerState(),
58
64
  reason = reason,
65
+ network = network(),
66
+ device = when (event) {
67
+ RecordEvent.HEARTBEAT -> details.forHeartbeat()
68
+ RecordEvent.TRACKING_START -> details.forTrackingStart()
69
+ else -> DeviceSnapshot()
70
+ }.copy(deviceId = details.deviceId()).takeUnless { it.isEmpty },
71
+ batteryDetails = if (event == RecordEvent.HEARTBEAT) details.batteryDetails() else null,
59
72
  )
60
73
  }
61
74
 
75
+ private fun network(): ConnectivityType? = try {
76
+ device.connectivity().type
77
+ } catch (e: Exception) {
78
+ Logger.w(TAG, "connectivity unavailable", e)
79
+ null
80
+ }
81
+
62
82
  /**
63
- * Parses a JS `InsertLocationInput`: `{coords:{latitude, longitude, accuracy?, altitude?, ...}, timestamp?,
64
- * event?, is_moving?, extras?}`. `accuracy` defaults to 0; negative optional accuracies, speed and heading
65
- * (the "unknown" sentinel of some platforms) become null.
83
+ * Parses a JS `InsertLocationInput`: `{coords:{latitude, longitude, accuracy, altitude?, ...}, timestamp?,
84
+ * event?, is_moving?, extras?}`. Negative optional accuracies, speed and heading (the "unknown" sentinel of some
85
+ * platforms) become null.
66
86
  *
67
87
  * @throws TrackingException INVALID_ARGUMENT if coords, latitude or longitude are missing or invalid, if
68
- * accuracy is negative, or if `timestamp`, `event`, `is_moving` or `extras` are given but invalid.
88
+ * accuracy is missing or not greater than 0, or if `timestamp`, `event`, `is_moving` or `extras` are given but invalid.
69
89
  */
70
90
  override fun fromExternal(input: JSONObject): Record {
71
91
  val coords = input.optJSONObject("coords") ?: invalid("location.coords is required")
@@ -90,8 +110,9 @@ class DefaultRecordFactory(
90
110
  } else {
91
111
  JsonUtil.optBoolean(input, "is_moving") ?: invalid("is_moving must be a boolean")
92
112
  }
93
- val accuracy = JsonUtil.optFloat(coords, "accuracy") ?: 0f
94
- if (accuracy < 0) invalid("coords.accuracy must be >= 0")
113
+ // Round 3: no default; an accuracy of 0 would read as a perfect fix.
114
+ val accuracy = JsonUtil.optFloat(coords, "accuracy") ?: invalid("coords.accuracy is required")
115
+ if (!(accuracy > 0)) invalid("coords.accuracy must be > 0")
95
116
  val extras = if (input.isNull("extras")) {
96
117
  null
97
118
  } else {
@@ -1,5 +1,6 @@
1
1
  package com.brickssoft.locationtracking.provider
2
2
 
3
+ import com.brickssoft.locationtracking.core.TrackingException
3
4
  import com.brickssoft.locationtracking.model.ActivitySample
4
5
  import com.brickssoft.locationtracking.model.DesiredAccuracy
5
6
  import com.brickssoft.locationtracking.model.GeofenceAction
@@ -17,6 +18,13 @@ data class LocationRequestSpec(
17
18
  /** Receives batches of fixes, on any thread. */
18
19
  fun interface LocationListener {
19
20
  fun onLocations(locations: List<TrackedLocation>)
21
+
22
+ /**
23
+ * The request made with this listener failed, synchronously or later (recommendation 3): for example a missing
24
+ * permission, Google Play services or HMS Core refusing the request, or no usable platform provider. The listener is
25
+ * no longer registered. Called at most once per request, on any thread.
26
+ */
27
+ fun onError(error: TrackingException) = Unit
20
28
  }
21
29
 
22
30
  /** Location updates and one-shot fixes from one backend. */
@@ -25,8 +33,8 @@ interface LocationBackend {
25
33
 
26
34
  /**
27
35
  * Supports multiple listeners; requesting again with the same listener replaces its request. A `distanceFilterM`
28
- * of 0 means no distance filter (the engine applies its own elastic filter). Never throws: failures such as a
29
- * missing permission are logged and the listener receives nothing.
36
+ * of 0 means no distance filter (the engine applies its own elastic filter). Never throws: a failure, also one the
37
+ * OS reports later, is logged and reported to [LocationListener.onError] (see [LocationSubscriptions]).
30
38
  */
31
39
  fun requestUpdates(spec: LocationRequestSpec, listener: LocationListener)
32
40
 
@@ -35,8 +43,18 @@ interface LocationBackend {
35
43
  /** The OS's cached last location. Implementations may throw `TrackingException(PERMISSION_DENIED)`; callers catch. */
36
44
  suspend fun getLastLocation(): TrackedLocation?
37
45
 
38
- /** Returns null on timeout. @throws com.brickssoft.locationtracking.core.TrackingException PERMISSION_DENIED */
46
+ /**
47
+ * Returns null on timeout. A request the OS refuses throws at once (recommendation 3c), not after the timeout.
48
+ * @throws com.brickssoft.locationtracking.core.TrackingException PERMISSION_DENIED, or the backend's mapping of
49
+ * the OS error (for example UNAVAILABLE).
50
+ */
39
51
  suspend fun getCurrentLocation(accuracy: DesiredAccuracy, timeoutMs: Long): TrackedLocation?
52
+
53
+ /**
54
+ * The last `LocationAvailability` that GMS or HMS reported for this backend's requests, or null (none yet, or the
55
+ * platform backend). A hint only: Google documents that it can be wrong in both directions.
56
+ */
57
+ val availabilityHint: Boolean? get() = null
40
58
  }
41
59
 
42
60
  /** Activity recognition. Samples are delivered through a PendingIntent receiver to [ActivitySink]. */
@@ -91,6 +109,12 @@ interface ProviderBundle {
91
109
  /** True if the SDK is packaged and usable on this device (e.g. `GoogleApiAvailability` SUCCESS). */
92
110
  fun isAvailable(): Boolean
93
111
 
112
+ /** The raw availability result code of the last [isAvailable] (0 = SUCCESS), or null if unknown. */
113
+ fun availabilityStatus(): Int? = null
114
+
115
+ /** A diagnostic about the installed SDK service that does not change the choice (HMS: an old HMS Core), or null. */
116
+ fun versionDiagnostic(): String? = null
117
+
94
118
  fun location(): LocationBackend
95
119
 
96
120
  fun activity(): ActivityBackend
@@ -112,8 +136,29 @@ interface ProviderFactory {
112
136
 
113
137
  /** Re-evaluates the selection (e.g. after setConfig). Returns true if [kind] changed. */
114
138
  fun reselect(): Boolean
139
+
140
+ /**
141
+ * What [reselect] would select now, without changing the selection. With `locationProvider: 'auto'` the engine
142
+ * compares it with [kind] on provider changes and heartbeats, and switches when it is better (recommendation 15).
143
+ */
144
+ fun bestAvailable(): ProviderKind = kind
145
+
146
+ /** Why the current backend was selected and the raw availability codes (recommendation 15); diagnostic only. */
147
+ fun selectionInfo(): ProviderSelectionInfo = ProviderSelectionInfo(fallbackReason = null, gmsStatus = null, hmsStatus = null)
115
148
  }
116
149
 
150
+ /**
151
+ * Diagnostics of the provider choice: [fallbackReason] says why a better backend was not used (null when the preferred
152
+ * one was), [gmsStatus] / [hmsStatus] are the raw `GoogleApiAvailability` / `HuaweiApiAvailability` result codes (null
153
+ * when that SDK is not packaged), and [hmsCoreOutdated] is true when HMS Core is older than 6.3.0.301.
154
+ */
155
+ data class ProviderSelectionInfo(
156
+ val fallbackReason: String?,
157
+ val gmsStatus: Int?,
158
+ val hmsStatus: Int?,
159
+ val hmsCoreOutdated: Boolean? = null,
160
+ )
161
+
117
162
  /** Implemented by the TrackingEngine. */
118
163
  interface ActivitySink {
119
164
  suspend fun onActivitySamples(samples: List<ActivitySample>)
@@ -43,6 +43,9 @@ class DefaultProviderFactory(
43
43
  /** Created bundles; a null value means the bundle could not be loaded. Guarded by [lock]. */
44
44
  private val bundles = HashMap<ProviderKind, ProviderBundle?>()
45
45
 
46
+ /** Bundles whose [ProviderBundle.versionDiagnostic] is logged once, when they are created. Guarded by [lock]. */
47
+ private val bundleVersionWarnings = HashSet<ProviderKind>()
48
+
46
49
  override val kind: ProviderKind get() = current().kind
47
50
 
48
51
  override fun location(): LocationBackend = current().bundle.location()
@@ -56,6 +59,56 @@ class DefaultProviderFactory(
56
59
  return isUsable(bundle)
57
60
  }
58
61
 
62
+ override fun bestAvailable(): ProviderKind = synchronized(lock) {
63
+ if (configStore.config.value.locationProvider != LocationProviderSetting.AUTO) return@synchronized kind
64
+ when {
65
+ availableLocked(ProviderKind.GMS) -> ProviderKind.GMS
66
+ availableLocked(ProviderKind.HMS) -> ProviderKind.HMS
67
+ else -> ProviderKind.ANDROID
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Why the current backend was selected (recommendation 15): for each better backend that was not used, its reason
73
+ * (not listed, SDK not packaged, receivers removed, or the availability status code); null when the preferred
74
+ * backend is used. The status codes are those of the last availability checks.
75
+ */
76
+ override fun selectionInfo(): ProviderSelectionInfo = synchronized(lock) {
77
+ val setting = configStore.config.value.locationProvider
78
+ val current = current().kind
79
+ val preferred = when (setting) {
80
+ LocationProviderSetting.AUTO -> listOf(ProviderKind.GMS, ProviderKind.HMS)
81
+ LocationProviderSetting.GMS -> listOf(ProviderKind.GMS)
82
+ LocationProviderSetting.HMS -> listOf(ProviderKind.HMS)
83
+ LocationProviderSetting.ANDROID -> emptyList()
84
+ }
85
+ val skipped = preferred.takeWhile { it != current }.map { kind -> "${kind.wire}: ${skipReasonLocked(kind)}" }
86
+ val hms = bundles[ProviderKind.HMS]
87
+ ProviderSelectionInfo(
88
+ fallbackReason = skipped.takeIf { it.isNotEmpty() }?.joinToString("; "),
89
+ gmsStatus = bundles[ProviderKind.GMS]?.availabilityStatus(),
90
+ hmsStatus = hms?.availabilityStatus(),
91
+ hmsCoreOutdated = hms?.let { guard("HMS Core version check") { it.versionDiagnostic() } != null },
92
+ )
93
+ }
94
+
95
+ private fun skipReasonLocked(kind: ProviderKind): String {
96
+ when (packaging.status(kind)) {
97
+ ProviderPackaging.Status.NOT_LISTED -> return "not listed in ${ProviderPackaging.PROVIDERS_META_DATA}"
98
+ ProviderPackaging.Status.SDK_MISSING -> return "SDK not packaged"
99
+ ProviderPackaging.Status.RECEIVERS_REMOVED -> return "receivers not in the manifest"
100
+ ProviderPackaging.Status.PACKAGED -> Unit
101
+ }
102
+ val bundle = bundleLocked(kind) ?: return "backend could not be loaded"
103
+ val available = isUsable(bundle)
104
+ val status = bundle.availabilityStatus()
105
+ return when {
106
+ available -> "available, not selected"
107
+ status != null -> "unavailable (status $status)"
108
+ else -> "unavailable"
109
+ }
110
+ }
111
+
59
112
  override fun reselect(): Boolean = synchronized(lock) {
60
113
  val previous = selection
61
114
  val next = selectLocked()
@@ -106,6 +159,10 @@ class DefaultProviderFactory(
106
159
  ProviderPackaging.Status.SDK_MISSING -> "SDK is not packaged"
107
160
  ProviderPackaging.Status.RECEIVERS_REMOVED -> "SDK is present but its receivers are not in the manifest"
108
161
  }
162
+ if (reason == null && kind == ProviderKind.HMS) {
163
+ // Diagnostic only (recommendation 15): HMS is used whatever the HMS Core version.
164
+ bundleVersionWarnings += kind
165
+ }
109
166
  if (reason != null) {
110
167
  // Listed in the app's PROVIDERS meta-data but unusable is a setup error; otherwise it is expected.
111
168
  if (packaging.listedExplicitly(kind)) {
@@ -117,6 +174,9 @@ class DefaultProviderFactory(
117
174
  }
118
175
  val name = ProviderBundles.bundleClassName(kind)
119
176
  val bundle = guard("create $name") { createBundle(name) }
177
+ if (bundle != null && kind in bundleVersionWarnings) {
178
+ guard("${kind.wire} version check") { bundle.versionDiagnostic() }?.let { Logger.w(TAG, it) }
179
+ }
120
180
  if (bundle == null && kind == ProviderKind.ANDROID) {
121
181
  // The last resort must always exist; it lives in this module, so it can be constructed directly.
122
182
  Logger.e(TAG, "reflective creation of the android bundle failed; constructing it directly")