@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2759 -2
  3. package/android/build.gradle +106 -0
  4. package/android/consumer-rules.pro +42 -0
  5. package/android/src/main/AndroidManifest.xml +52 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
  15. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
  17. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
  30. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
  37. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
  40. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
  42. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
  43. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
  51. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
  53. package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
  54. package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
  55. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
  56. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
  57. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
  58. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
  59. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
  60. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
  61. package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
  62. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
  63. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
  64. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
  65. package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
  66. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
  67. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
  68. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
  69. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
  70. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
  71. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
  72. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
  73. package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
  74. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
  75. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
  76. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
  77. package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
  78. package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
  79. package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
  80. package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
  81. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
  82. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
  83. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
  84. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
  85. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
  86. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
  87. package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
  88. package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
  89. package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
  90. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
  91. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
  92. package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
  93. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
  94. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
  95. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
  96. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
  97. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
  98. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
  99. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
  100. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
  101. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
  102. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
  103. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
  104. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
  105. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
  106. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
  107. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
  108. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
  109. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
  110. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
  111. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
  112. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
  113. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
  114. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
  115. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
  116. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
  117. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
  118. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
  119. package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
  120. package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
  121. package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
  122. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
  123. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
  124. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
  125. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
  126. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
  127. package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
  128. package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
  129. package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
  130. package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
  131. package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
  132. package/android/src/main/res/values/lt_strings.xml +11 -0
  133. package/android/src/main/res/xml/lt_file_paths.xml +5 -0
  134. package/dist/esm/definitions.d.ts +719 -0
  135. package/dist/esm/definitions.js +2 -0
  136. package/dist/esm/definitions.js.map +1 -0
  137. package/dist/esm/events.d.ts +50 -0
  138. package/dist/esm/events.js +52 -0
  139. package/dist/esm/events.js.map +1 -0
  140. package/dist/esm/index.d.ts +3 -0
  141. package/dist/esm/index.js +5 -0
  142. package/dist/esm/index.js.map +1 -0
  143. package/dist/esm/plugin.d.ts +3 -0
  144. package/dist/esm/plugin.js +10 -0
  145. package/dist/esm/plugin.js.map +1 -0
  146. package/dist/esm/web/config.d.ts +19 -0
  147. package/dist/esm/web/config.js +243 -0
  148. package/dist/esm/web/config.js.map +1 -0
  149. package/dist/esm/web/device.d.ts +9 -0
  150. package/dist/esm/web/device.js +90 -0
  151. package/dist/esm/web/device.js.map +1 -0
  152. package/dist/esm/web/errors.d.ts +14 -0
  153. package/dist/esm/web/errors.js +19 -0
  154. package/dist/esm/web/errors.js.map +1 -0
  155. package/dist/esm/web/geolocation.d.ts +26 -0
  156. package/dist/esm/web/geolocation.js +117 -0
  157. package/dist/esm/web/geolocation.js.map +1 -0
  158. package/dist/esm/web/location.d.ts +7 -0
  159. package/dist/esm/web/location.js +70 -0
  160. package/dist/esm/web/location.js.map +1 -0
  161. package/dist/esm/web.d.ts +164 -0
  162. package/dist/esm/web.js +328 -0
  163. package/dist/esm/web.js.map +1 -0
  164. package/dist/plugin.cjs.js +945 -0
  165. package/dist/plugin.cjs.js.map +1 -0
  166. package/dist/plugin.js +948 -0
  167. package/dist/plugin.js.map +1 -0
  168. package/package.json +59 -4
@@ -0,0 +1,277 @@
1
+ package com.brickssoft.locationtracking.position
2
+
3
+ import com.brickssoft.locationtracking.config.ConfigStore
4
+ import com.brickssoft.locationtracking.core.Clock
5
+ import com.brickssoft.locationtracking.core.ErrorCode
6
+ import com.brickssoft.locationtracking.core.Logger
7
+ import com.brickssoft.locationtracking.core.TrackingException
8
+ import com.brickssoft.locationtracking.device.DeviceMonitor
9
+ import com.brickssoft.locationtracking.model.Record
10
+ import com.brickssoft.locationtracking.model.RecordEvent
11
+ import com.brickssoft.locationtracking.model.TrackedLocation
12
+ import com.brickssoft.locationtracking.permission.PermissionManager
13
+ import com.brickssoft.locationtracking.processing.RecordFactory
14
+ import com.brickssoft.locationtracking.provider.LocationBackend
15
+ import com.brickssoft.locationtracking.provider.LocationListener
16
+ import com.brickssoft.locationtracking.provider.LocationRequestSpec
17
+ import com.brickssoft.locationtracking.provider.ProviderFactory
18
+ import com.brickssoft.locationtracking.record.RecordSink
19
+ import kotlinx.coroutines.CoroutineScope
20
+ import kotlinx.coroutines.channels.Channel
21
+ import kotlinx.coroutines.currentCoroutineContext
22
+ import kotlinx.coroutines.ensureActive
23
+ import kotlinx.coroutines.withTimeoutOrNull
24
+ import java.util.concurrent.ConcurrentHashMap
25
+ import kotlin.coroutines.cancellation.CancellationException
26
+ import kotlin.math.max
27
+ import kotlin.math.min
28
+
29
+ /**
30
+ * Default [PositionService].
31
+ *
32
+ * - [getCurrentPosition] requires foreground location permission and enabled location services. With
33
+ * `maximumAgeMs > 0` it returns the freshest known fix (runtime or backend last location) if it is young
34
+ * enough. Otherwise it samples a temporary backend request for up to `samples` fixes, stops early on a fix of
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.
37
+ * - [watchPosition] registers one backend listener per watch id. Each fix becomes a `watch_position` record,
38
+ * optionally persisted through [recordSink], and is delivered in order from [scope] (see [PositionWatch]).
39
+ *
40
+ * Mock fixes are ignored everywhere while `geolocation.filter.rejectMockLocations` is set.
41
+ */
42
+ class DefaultPositionService(
43
+ private val providers: ProviderFactory,
44
+ private val configStore: ConfigStore,
45
+ private val permissions: PermissionManager,
46
+ private val device: DeviceMonitor,
47
+ private val recordFactory: RecordFactory,
48
+ private val recordSink: RecordSink,
49
+ private val clock: Clock,
50
+ private val scope: CoroutineScope,
51
+ ) : PositionService {
52
+ private val watches = ConcurrentHashMap<String, PositionWatch>()
53
+
54
+ override suspend fun getCurrentPosition(o: CurrentPositionOptions): Record {
55
+ if (!permissions.hasForegroundLocation()) {
56
+ throw TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted")
57
+ }
58
+ if (!device.providerState().enabled) {
59
+ throw TrackingException(ErrorCode.LOCATION_DISABLED, "Location services are disabled")
60
+ }
61
+ val geolocation = configStore.config.value.geolocation
62
+ val rejectMock = geolocation.filter.rejectMockLocations
63
+ val timeoutMs = o.timeoutMs ?: geolocation.locationTimeout
64
+ val backend = providers.location()
65
+ val startedAt = clock.elapsedRealtime()
66
+
67
+ // The timeout covers the whole call, including the last-location lookup of the maximumAge check.
68
+ val location = cachedLocation(backend, o.maximumAgeMs, rejectMock, timeoutMs)
69
+ ?: sample(backend, o, timeoutMs - (clock.elapsedRealtime() - startedAt), rejectMock)
70
+ ?: throw TrackingException(ErrorCode.TIMEOUT, "No location fix within ${timeoutMs}ms")
71
+ val record = recordFactory.create(RecordEvent.CURRENT_POSITION, location, extras = o.extras)
72
+ return if (o.persist) recordSink.submit(record) else record
73
+ }
74
+
75
+ override fun watchPosition(id: String, o: WatchPositionOptions, callback: (Record?, TrackingException?) -> Unit) {
76
+ watches.remove(id)?.stop()
77
+ if (!permissions.hasForegroundLocation()) {
78
+ deliverError(id, callback, TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted"))
79
+ return
80
+ }
81
+ if (!device.providerState().enabled) {
82
+ // Not an error: the backend starts delivering once location services are switched on.
83
+ Logger.w(TAG, "watch $id: location services are disabled")
84
+ }
85
+ val interval = o.intervalMs.coerceAtLeast(0L)
86
+ val spec = LocationRequestSpec(o.desiredAccuracy, interval, interval / 2, 0f)
87
+ val watch = PositionWatch(id, providers.location(), spec, callback) { fix -> watchRecord(fix, o) }
88
+ // A concurrent watchPosition() for the same id may have registered in the meantime: last one wins.
89
+ watches.put(id, watch)?.stop()
90
+ val error = watch.start(scope) ?: return
91
+ watches.remove(id, watch)
92
+ deliverError(id, callback, error)
93
+ }
94
+
95
+ override fun clearWatch(id: String): Boolean {
96
+ val watch = watches.remove(id) ?: return false
97
+ watch.stop()
98
+ return true
99
+ }
100
+
101
+ override fun clearAllWatches() {
102
+ for ((id, watch) in watches) {
103
+ if (watches.remove(id, watch)) watch.stop()
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Freshest acceptable known fix no older than [maximumAgeMs], or null (always null when [maximumAgeMs] <= 0).
109
+ * The backend lookup takes at most [LAST_LOCATION_TIMEOUT_MS] and never longer than [timeoutMs].
110
+ */
111
+ private suspend fun cachedLocation(
112
+ backend: LocationBackend,
113
+ maximumAgeMs: Long,
114
+ rejectMock: Boolean,
115
+ timeoutMs: Long,
116
+ ): TrackedLocation? {
117
+ if (maximumAgeMs <= 0) return null
118
+ val known = listOfNotNull(
119
+ configStore.runtime.value.lastLocation,
120
+ lastBackendLocation(backend, min(LAST_LOCATION_TIMEOUT_MS, timeoutMs)),
121
+ )
122
+ val freshest = known.filter { accepts(it, rejectMock) }.minByOrNull { ageOf(it) } ?: return null
123
+ val age = ageOf(freshest)
124
+ if (age > maximumAgeMs) return null
125
+ Logger.d(TAG, "getCurrentPosition: using known fix (age ${age}ms, accuracy ${freshest.accuracy}m)")
126
+ return freshest
127
+ }
128
+
129
+ private suspend fun lastBackendLocation(backend: LocationBackend, timeoutMs: Long): TrackedLocation? = try {
130
+ withTimeoutOrNull(timeoutMs) { backend.getLastLocation() }
131
+ } catch (e: CancellationException) {
132
+ currentCoroutineContext().ensureActive() // the caller was cancelled: propagate
133
+ Logger.w(TAG, "getLastLocation was cancelled by the backend", e)
134
+ null
135
+ } catch (e: Exception) {
136
+ Logger.w(TAG, "getLastLocation failed", e)
137
+ null
138
+ }
139
+
140
+ /**
141
+ * Age of [fix] in ms: the larger of its wall-clock age and, when the fix carries an elapsed-realtime timestamp,
142
+ * its elapsed-realtime age. A wall-clock change therefore cannot make an old fix look fresh, and a timestamp from
143
+ * before a reboot only ever yields a smaller elapsed age, which the wall-clock age then dominates.
144
+ */
145
+ private fun ageOf(fix: TrackedLocation): Long {
146
+ val wallAge = clock.now() - fix.time
147
+ if (fix.elapsedRealtimeNanos <= 0L) return wallAge
148
+ return max(wallAge, clock.elapsedRealtime() - fix.elapsedRealtimeNanos / NANOS_PER_MS)
149
+ }
150
+
151
+ /**
152
+ * Collects fixes from a temporary backend request until [SampleCollector] is satisfied or [timeoutMs] expires,
153
+ * and returns the best one, or null if no acceptable fix arrived. The listener is always removed.
154
+ * Historical fixes computed before the request (by the elapsed-realtime clock) are ignored.
155
+ */
156
+ private suspend fun sample(
157
+ backend: LocationBackend,
158
+ o: CurrentPositionOptions,
159
+ timeoutMs: Long,
160
+ rejectMock: Boolean,
161
+ ): TrackedLocation? {
162
+ if (timeoutMs <= 0) return null
163
+ val collector = SampleCollector(o.samples)
164
+ val fixes = Channel<TrackedLocation>(Channel.UNLIMITED)
165
+ val listener = LocationListener { batch -> batch.forEach { fixes.trySend(it) } }
166
+ val spec = LocationRequestSpec(o.desiredAccuracy, SAMPLE_INTERVAL_MS, SAMPLE_FASTEST_INTERVAL_MS, 0f)
167
+ val requestedAt = clock.elapsedRealtime()
168
+ try {
169
+ backend.requestUpdates(spec, listener)
170
+ withTimeoutOrNull(timeoutMs) {
171
+ for (fix in fixes) {
172
+ if (accepts(fix, rejectMock) && !isStaleSample(fix, requestedAt) && collector.add(fix)) break
173
+ }
174
+ }
175
+ } catch (e: CancellationException) {
176
+ currentCoroutineContext().ensureActive() // the caller was cancelled: propagate
177
+ throw backendError(e)
178
+ } catch (e: Exception) {
179
+ throw backendError(e)
180
+ } finally {
181
+ fixes.close()
182
+ removeQuietly(backend, listener)
183
+ }
184
+ val best = collector.best ?: return null
185
+ Logger.d(TAG, "getCurrentPosition: ${collector.count} sample(s), best accuracy ${best.accuracy}m")
186
+ return best
187
+ }
188
+
189
+ private fun isStaleSample(fix: TrackedLocation, requestedAt: Long): Boolean =
190
+ fix.elapsedRealtimeNanos > 0L && fix.elapsedRealtimeNanos / NANOS_PER_MS < requestedAt - STALE_SAMPLE_TOLERANCE_MS
191
+
192
+ /** Record for a watch fix, persisted if requested; null if the fix is rejected by the mock rule. */
193
+ private suspend fun watchRecord(fix: TrackedLocation, o: WatchPositionOptions): Record? {
194
+ if (!accepts(fix, configStore.config.value.geolocation.filter.rejectMockLocations)) return null
195
+ val record = recordFactory.create(RecordEvent.WATCH_POSITION, fix, extras = o.extras)
196
+ return if (o.persist) recordSink.submit(record) else record
197
+ }
198
+
199
+ private fun deliverError(id: String, callback: (Record?, TrackingException?) -> Unit, error: TrackingException) {
200
+ Logger.w(TAG, "watch $id failed: ${error.code} ${error.message}")
201
+ try {
202
+ callback(null, error)
203
+ } catch (e: Exception) {
204
+ Logger.e(TAG, "watch $id callback failed", e)
205
+ }
206
+ }
207
+
208
+ internal companion object {
209
+ const val TAG = "LT.Position"
210
+
211
+ /** Interval of the temporary sampling request of getCurrentPosition, ms. */
212
+ const val SAMPLE_INTERVAL_MS = 1_000L
213
+
214
+ /** Fastest interval of the temporary sampling request of getCurrentPosition, ms. */
215
+ const val SAMPLE_FASTEST_INTERVAL_MS = 500L
216
+
217
+ /** A fix at least this accurate (m) ends sampling early. */
218
+ const val ACCURATE_ENOUGH_M = 10f
219
+
220
+ /** Upper bound for the backend's last-location lookup of the maximumAge check, ms. */
221
+ const val LAST_LOCATION_TIMEOUT_MS = 5_000L
222
+
223
+ /** A sample computed more than this long before the sampling request started is historical, ms. */
224
+ const val STALE_SAMPLE_TOLERANCE_MS = 2_000L
225
+
226
+ private const val NANOS_PER_MS = 1_000_000L
227
+
228
+ /** False for mock fixes while [rejectMock] is set. */
229
+ fun accepts(fix: TrackedLocation, rejectMock: Boolean): Boolean = !(rejectMock && fix.isMock)
230
+
231
+ /** Maps a backend failure to the error reported to JS. */
232
+ fun backendError(e: Exception): TrackingException = when (e) {
233
+ is TrackingException -> e
234
+ is SecurityException -> TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted", e)
235
+ else -> TrackingException(ErrorCode.UNAVAILABLE, "Location updates are unavailable: ${e.message}", e)
236
+ }
237
+
238
+ fun removeQuietly(backend: LocationBackend, listener: LocationListener) {
239
+ try {
240
+ backend.removeUpdates(listener)
241
+ } catch (e: Exception) {
242
+ Logger.w(TAG, "removeUpdates failed", e)
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Best-of-N selection for getCurrentPosition. [add] returns true once [wanted] fixes were collected or the best
250
+ * fix is at least [DefaultPositionService.ACCURATE_ENOUGH_M] accurate. A fix without a usable accuracy (0, negative
251
+ * or NaN) ranks last; on equal accuracy the fix with the newer (or equal) fix time wins. Not thread-safe.
252
+ */
253
+ internal class SampleCollector(wanted: Int) {
254
+ private val wanted = wanted.coerceAtLeast(1)
255
+
256
+ var best: TrackedLocation? = null
257
+ private set
258
+
259
+ var count = 0
260
+ private set
261
+
262
+ fun add(fix: TrackedLocation): Boolean {
263
+ count++
264
+ val current = best
265
+ val winner = if (current == null || isBetter(fix, current)) fix else current
266
+ best = winner
267
+ return count >= wanted || rank(winner) <= DefaultPositionService.ACCURATE_ENOUGH_M
268
+ }
269
+
270
+ private fun isBetter(fix: TrackedLocation, current: TrackedLocation): Boolean {
271
+ val a = rank(fix)
272
+ val b = rank(current)
273
+ return a < b || (a == b && fix.time >= current.time)
274
+ }
275
+
276
+ private fun rank(fix: TrackedLocation): Float = if (fix.accuracy > 0f) fix.accuracy else Float.POSITIVE_INFINITY
277
+ }
@@ -0,0 +1,39 @@
1
+ package com.brickssoft.locationtracking.position
2
+
3
+ import com.brickssoft.locationtracking.core.TrackingException
4
+ import com.brickssoft.locationtracking.model.DesiredAccuracy
5
+ import com.brickssoft.locationtracking.model.Record
6
+
7
+ /**
8
+ * @property timeoutMs null = `geolocation.locationTimeout`.
9
+ * @property extras JSON object text.
10
+ */
11
+ data class CurrentPositionOptions(
12
+ val samples: Int = 3,
13
+ val timeoutMs: Long? = null,
14
+ val maximumAgeMs: Long = 0,
15
+ val desiredAccuracy: DesiredAccuracy = DesiredAccuracy.HIGH,
16
+ val persist: Boolean = true,
17
+ val extras: String? = null,
18
+ )
19
+
20
+ /** @property extras JSON object text. */
21
+ data class WatchPositionOptions(
22
+ val intervalMs: Long = 1000,
23
+ val desiredAccuracy: DesiredAccuracy = DesiredAccuracy.HIGH,
24
+ val persist: Boolean = false,
25
+ val extras: String? = null,
26
+ )
27
+
28
+ interface PositionService {
29
+ /** @throws TrackingException PERMISSION_DENIED, LOCATION_DISABLED, TIMEOUT, ... */
30
+ suspend fun getCurrentPosition(o: CurrentPositionOptions): Record
31
+
32
+ /** [callback] receives either a record or an error, possibly many times, until [clearWatch]. */
33
+ fun watchPosition(id: String, o: WatchPositionOptions, callback: (Record?, TrackingException?) -> Unit)
34
+
35
+ /** Returns false if [id] was not watching. */
36
+ fun clearWatch(id: String): Boolean
37
+
38
+ fun clearAllWatches()
39
+ }
@@ -0,0 +1,115 @@
1
+ package com.brickssoft.locationtracking.position
2
+
3
+ import com.brickssoft.locationtracking.core.ErrorCode
4
+ import com.brickssoft.locationtracking.core.Logger
5
+ import com.brickssoft.locationtracking.core.TrackingException
6
+ import com.brickssoft.locationtracking.model.Record
7
+ import com.brickssoft.locationtracking.model.TrackedLocation
8
+ import com.brickssoft.locationtracking.provider.LocationBackend
9
+ import com.brickssoft.locationtracking.provider.LocationListener
10
+ import com.brickssoft.locationtracking.provider.LocationRequestSpec
11
+ import kotlinx.coroutines.CoroutineScope
12
+ import kotlinx.coroutines.channels.BufferOverflow
13
+ import kotlinx.coroutines.channels.Channel
14
+ import kotlinx.coroutines.currentCoroutineContext
15
+ import kotlinx.coroutines.ensureActive
16
+ import kotlinx.coroutines.launch
17
+ import kotlin.coroutines.cancellation.CancellationException
18
+
19
+ /**
20
+ * One active `watchPosition`. The backend listener only enqueues fixes; a single consumer coroutine on the
21
+ * given scope turns each fix into a record (through [toRecord], which may persist it) and invokes [callback],
22
+ * so deliveries keep the fix order and never run on the backend's thread.
23
+ *
24
+ * After [stop] returns, the listener is removed, queued fixes are dropped and [callback] is never invoked again
25
+ * (a callback that is running when [stop] is called finishes first); a record that is already being persisted
26
+ * finishes persisting. At most [BUFFER] fixes wait for the consumer; beyond that the oldest are dropped.
27
+ * [start] and [stop] are thread-safe and idempotent.
28
+ *
29
+ * @param toRecord returns null to skip a fix (mock rule).
30
+ */
31
+ internal class PositionWatch(
32
+ val id: String,
33
+ private val backend: LocationBackend,
34
+ private val spec: LocationRequestSpec,
35
+ private val callback: (Record?, TrackingException?) -> Unit,
36
+ private val toRecord: suspend (TrackedLocation) -> Record?,
37
+ ) {
38
+ /** Guards [started], [stopped], [registered] and every [callback] invocation. */
39
+ private val lock = Any()
40
+ private val fixes = Channel<TrackedLocation>(BUFFER, BufferOverflow.DROP_OLDEST)
41
+ private val listener = LocationListener { batch -> batch.forEach { fixes.trySend(it) } }
42
+
43
+ @Volatile
44
+ private var stopped = false
45
+ private var started = false
46
+ private var registered = false
47
+
48
+ /**
49
+ * Starts the consumer on [scope] and registers the backend listener. Returns the error if the backend refused
50
+ * (the watch is then stopped and [callback] is not invoked); null on success or if already started/stopped.
51
+ */
52
+ fun start(scope: CoroutineScope): TrackingException? {
53
+ synchronized(lock) {
54
+ if (stopped || started) return null
55
+ started = true
56
+ scope.launch { consume() }
57
+ try {
58
+ backend.requestUpdates(spec, listener)
59
+ registered = true
60
+ Logger.d(DefaultPositionService.TAG, "watch $id started (${spec.accuracy.wire}, ${spec.intervalMs}ms)")
61
+ return null
62
+ } catch (e: Exception) {
63
+ stopped = true
64
+ fixes.close()
65
+ // A backend may have registered before failing.
66
+ DefaultPositionService.removeQuietly(backend, listener)
67
+ return DefaultPositionService.backendError(e)
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Removes the backend listener and ends deliveries. */
73
+ fun stop() {
74
+ val wasRegistered = synchronized(lock) {
75
+ if (stopped) return
76
+ stopped = true
77
+ registered.also { registered = false }
78
+ }
79
+ fixes.close()
80
+ if (wasRegistered) DefaultPositionService.removeQuietly(backend, listener)
81
+ Logger.d(DefaultPositionService.TAG, "watch $id cleared")
82
+ }
83
+
84
+ private suspend fun consume() {
85
+ for (fix in fixes) {
86
+ if (stopped) break
87
+ val record = try {
88
+ toRecord(fix) ?: continue
89
+ } catch (e: Exception) {
90
+ // A stray CancellationException from a collaborator is a failure; our own cancellation propagates.
91
+ if (e is CancellationException) currentCoroutineContext().ensureActive()
92
+ Logger.e(DefaultPositionService.TAG, "watch $id: failed to build record", e)
93
+ deliver(null, TrackingException(ErrorCode.INTERNAL, "Failed to record watched position: ${e.message}", e))
94
+ continue
95
+ }
96
+ deliver(record, null)
97
+ }
98
+ }
99
+
100
+ private fun deliver(record: Record?, error: TrackingException?) {
101
+ synchronized(lock) {
102
+ if (stopped) return
103
+ try {
104
+ callback(record, error)
105
+ } catch (e: Exception) {
106
+ Logger.e(DefaultPositionService.TAG, "watch $id callback failed", e)
107
+ }
108
+ }
109
+ }
110
+
111
+ private companion object {
112
+ /** Fixes that may wait for the consumer (a slow sink); older ones are dropped first. */
113
+ const val BUFFER = 64
114
+ }
115
+ }
@@ -0,0 +1,93 @@
1
+ package com.brickssoft.locationtracking.processing
2
+
3
+ import com.brickssoft.locationtracking.config.ConfigStore
4
+ import com.brickssoft.locationtracking.config.LocationFilterConfig
5
+ import com.brickssoft.locationtracking.core.Logger
6
+ import com.brickssoft.locationtracking.model.TrackedLocation
7
+ import java.util.Locale
8
+
9
+ /**
10
+ * Location filters and optional Kalman smoothing. Reads `geolocation.filter` live from [configStore] on
11
+ * every fix.
12
+ *
13
+ * Rules, in order (the first match rejects):
14
+ * 1. invalid coordinates (non-finite, out of range, or exactly 0,0 — a known provider glitch);
15
+ * 2. `accuracy > trackingAccuracyThreshold` (a non-finite accuracy is also rejected);
16
+ * 3. a mock fix when `rejectMockLocations`;
17
+ * 4. a stale fix, older than the previous accepted fix;
18
+ * 5. an identical fix (same latitude, longitude and time as the previous accepted fix) unless
19
+ * `allowIdenticalLocations`;
20
+ * 6. implied speed (haversine distance / time since the previous accepted fix, only when that time is > 0)
21
+ * above `maxImpliedSpeed` (0 = off).
22
+ *
23
+ * Time between fixes uses the elapsed-realtime clock when both fixes carry it (see [Geo.elapsedMs]), so
24
+ * wall-clock changes do not affect the checks. Accepted fixes are smoothed by [KalmanFilter] when
25
+ * `useKalman` is set. The stale, identical and implied-speed checks compare raw fixes. Thread-safe.
26
+ */
27
+ class DefaultLocationProcessor(private val configStore: ConfigStore) : LocationProcessor {
28
+ private val lock = Any()
29
+ private val kalman = KalmanFilter()
30
+
31
+ /** The previous accepted fix, before smoothing. */
32
+ private var lastAccepted: TrackedLocation? = null
33
+
34
+ override fun process(raw: TrackedLocation, isMoving: Boolean): FilterResult {
35
+ val filter = configStore.config.value.geolocation.filter
36
+ val result = synchronized(lock) {
37
+ val rejection = rejectionReason(raw, filter)
38
+ if (rejection != null) {
39
+ FilterResult.Rejected(rejection)
40
+ } else {
41
+ lastAccepted = raw
42
+ val location = if (filter.useKalman) {
43
+ kalman.filter(raw)
44
+ } else {
45
+ // Start fresh if smoothing is turned on again later.
46
+ kalman.reset()
47
+ raw
48
+ }
49
+ FilterResult.Accepted(location)
50
+ }
51
+ }
52
+ if (result is FilterResult.Rejected) Logger.d(TAG, "rejected fix: ${result.reason}")
53
+ return result
54
+ }
55
+
56
+ override fun reset() = synchronized(lock) {
57
+ lastAccepted = null
58
+ kalman.reset()
59
+ }
60
+
61
+ private fun rejectionReason(raw: TrackedLocation, filter: LocationFilterConfig): String? {
62
+ if (!Geo.isValid(raw.latitude, raw.longitude) || (raw.latitude == 0.0 && raw.longitude == 0.0)) {
63
+ return "invalid coordinates (${raw.latitude}, ${raw.longitude})"
64
+ }
65
+ val threshold = filter.trackingAccuracyThreshold
66
+ if (!(raw.accuracy.toDouble() <= threshold)) {
67
+ return "accuracy ${raw.accuracy} m > trackingAccuracyThreshold $threshold m"
68
+ }
69
+ if (raw.isMock && filter.rejectMockLocations) return "mock location"
70
+ val previous = lastAccepted ?: return null
71
+ val dtMs = Geo.elapsedMs(previous, raw)
72
+ if (dtMs < 0) return "stale: ${-dtMs} ms older than the previous fix"
73
+ if (!filter.allowIdenticalLocations &&
74
+ raw.latitude == previous.latitude &&
75
+ raw.longitude == previous.longitude &&
76
+ raw.time == previous.time
77
+ ) {
78
+ return "identical to the previous fix"
79
+ }
80
+ val maxSpeed = filter.maxImpliedSpeed
81
+ if (maxSpeed > 0 && dtMs > 0) {
82
+ val speed = Geo.distance(previous, raw) / (dtMs / 1000.0)
83
+ if (speed > maxSpeed) {
84
+ return "implied speed ${"%.1f".format(Locale.US, speed)} m/s > maxImpliedSpeed $maxSpeed m/s"
85
+ }
86
+ }
87
+ return null
88
+ }
89
+
90
+ private companion object {
91
+ const val TAG = "LT.Processor"
92
+ }
93
+ }
@@ -0,0 +1,99 @@
1
+ package com.brickssoft.locationtracking.processing
2
+
3
+ import com.brickssoft.locationtracking.config.ConfigStore
4
+ import com.brickssoft.locationtracking.config.RuntimeState
5
+ import com.brickssoft.locationtracking.core.ErrorCode
6
+ import com.brickssoft.locationtracking.core.Logger
7
+ import com.brickssoft.locationtracking.core.TrackingException
8
+ import com.brickssoft.locationtracking.model.TrackedLocation
9
+
10
+ /**
11
+ * Distance accumulator persisted in `runtime.odometer` via [ConfigStore.updateRuntime].
12
+ *
13
+ * Adds the haversine distance between consecutive fixes whose accuracy is at most
14
+ * `geolocation.filter.odometerAccuracyThreshold`; a less accurate fix is skipped and the previous anchor is
15
+ * kept. [reset] also clears the anchor; [set] keeps it.
16
+ *
17
+ * The anchor belongs to one tracking session (`runtime.trackingStartedAt`), so distance travelled while tracking
18
+ * was off is never counted. The first fix of a session in this process is measured from `runtime.lastLocation` if
19
+ * that was recorded in the same session (and is accurate enough and not newer than the fix), otherwise it only
20
+ * becomes the anchor:
21
+ * - after `start()`, `lastLocation` is the session's start position (the initial `motionchange`), which the engine
22
+ * does not feed to the odometer while stationary, so the leg to the first moving fix is counted in every session;
23
+ * - after a cold process start (e.g. an OEM task killer and `restore`), it is where the previous process left off.
24
+ *
25
+ * [reset] clears the anchor for the rest of the current session. Thread-safe.
26
+ */
27
+ class DefaultOdometer(private val configStore: ConfigStore) : Odometer {
28
+ private val lock = Any()
29
+
30
+ /** The last fix that passed the accuracy gate. In memory only. */
31
+ private var anchor: TrackedLocation? = null
32
+
33
+ /** `trackingStartedAt` of the session [anchor] belongs to; null until this process's first fix or [reset]. */
34
+ private var anchorSession: Long? = null
35
+
36
+ override val value: Double get() = configStore.runtime.value.odometer
37
+
38
+ override fun onLocation(location: TrackedLocation) {
39
+ val threshold = configStore.config.value.geolocation.filter.odometerAccuracyThreshold
40
+ if (!passes(location, threshold)) {
41
+ Logger.v(TAG, "odometer skips fix with accuracy ${location.accuracy} m (threshold $threshold m)")
42
+ return
43
+ }
44
+ synchronized(lock) {
45
+ val runtime = configStore.runtime.value
46
+ val session = runtime.trackingStartedAt
47
+ if (anchorSession != session) {
48
+ // Every new session is seeded, not only the first fix of the process: a later session used to start
49
+ // from a null anchor, which dropped the leg from its start position to its first moving fix.
50
+ anchor = sessionAnchor(runtime, threshold, location)
51
+ anchorSession = session
52
+ }
53
+ val previous = anchor
54
+ anchor = location
55
+ if (previous == null) return
56
+ val distance = Geo.distance(previous, location)
57
+ if (distance > 0) configStore.updateRuntime { it.copy(odometer = it.odometer + distance) }
58
+ }
59
+ }
60
+
61
+ /** @throws TrackingException INVALID_ARGUMENT if [value] is negative or not finite. */
62
+ override fun set(value: Double) {
63
+ if (!value.isFinite() || value < 0) {
64
+ throw TrackingException(ErrorCode.INVALID_ARGUMENT, "odometer must be a finite number >= 0, got $value")
65
+ }
66
+ synchronized(lock) {
67
+ configStore.updateRuntime { it.copy(odometer = value) }
68
+ }
69
+ }
70
+
71
+ override fun reset() {
72
+ synchronized(lock) {
73
+ anchor = null
74
+ // The cleared anchor belongs to the current session: its next fix is not seeded from a lastLocation that
75
+ // may predate the reset.
76
+ anchorSession = configStore.runtime.value.trackingStartedAt
77
+ configStore.updateRuntime { it.copy(odometer = 0.0) }
78
+ }
79
+ }
80
+
81
+ private fun passes(location: TrackedLocation, threshold: Double): Boolean =
82
+ location.accuracy.toDouble() <= threshold && Geo.isValid(location.latitude, location.longitude)
83
+
84
+ /**
85
+ * The persisted last location, if it was recorded in the current tracking session, is accurate enough and is not
86
+ * newer than [next] (the fix about to be measured from it).
87
+ */
88
+ private fun sessionAnchor(runtime: RuntimeState, threshold: Double, next: TrackedLocation): TrackedLocation? {
89
+ val session = runtime.trackingStartedAt ?: return null
90
+ val last = runtime.lastLocation ?: return null
91
+ if (!runtime.enabled || last.time < session || last.time > next.time || !passes(last, threshold)) return null
92
+ Logger.d(TAG, "odometer starts from the last recorded location of this session")
93
+ return last
94
+ }
95
+
96
+ private companion object {
97
+ const val TAG = "LT.Odometer"
98
+ }
99
+ }