@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,68 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.config.GeolocationConfig
4
+ import com.brickssoft.locationtracking.model.TrackedLocation
5
+ import kotlin.math.asin
6
+ import kotlin.math.cos
7
+ import kotlin.math.floor
8
+ import kotlin.math.max
9
+ import kotlin.math.min
10
+ import kotlin.math.sin
11
+ import kotlin.math.sqrt
12
+
13
+ /**
14
+ * Elastic distance filter: the faster the device moves, the farther apart recorded locations are.
15
+ *
16
+ * `d = distanceFilter * max(1, round(speed / 5.0) * elasticityMultiplier)`, or plain `distanceFilter` when
17
+ * elasticity is disabled. `round` rounds halves up; a missing, negative or non-finite speed counts as 0.
18
+ */
19
+ internal object ElasticDistance {
20
+ /** Speed step (m/s) of the elastic multiplier. */
21
+ private const val SPEED_STEP_MPS = 5.0
22
+
23
+ /** The minimum distance (m) between two recorded locations at [speedMps]. */
24
+ fun threshold(
25
+ distanceFilter: Double,
26
+ speedMps: Float?,
27
+ elasticityMultiplier: Double,
28
+ disableElasticity: Boolean,
29
+ ): Double {
30
+ val base = if (distanceFilter.isFinite()) max(0.0, distanceFilter) else 0.0
31
+ if (disableElasticity) return base
32
+ val speed = speedMps?.toDouble()?.takeIf { it.isFinite() && it > 0.0 } ?: 0.0
33
+ val multiplier = if (elasticityMultiplier.isFinite()) elasticityMultiplier else 1.0
34
+ val factor = max(1.0, floor(speed / SPEED_STEP_MPS + 0.5) * multiplier)
35
+ return base * factor
36
+ }
37
+
38
+ /** [threshold] with the settings of [config], at the speed of [location]. */
39
+ fun threshold(config: GeolocationConfig, location: TrackedLocation): Double =
40
+ threshold(config.distanceFilter, location.speed, config.elasticityMultiplier, config.disableElasticity)
41
+
42
+ /** True if [candidate] is far enough from [lastRecorded] (or nothing was recorded yet) to be recorded. */
43
+ fun shouldRecord(lastRecorded: TrackedLocation?, candidate: TrackedLocation, config: GeolocationConfig): Boolean {
44
+ if (lastRecorded == null) return true
45
+ return distanceMeters(lastRecorded, candidate) >= threshold(config, candidate)
46
+ }
47
+ }
48
+
49
+ /** Mean Earth radius (m), IUGG. */
50
+ private const val EARTH_RADIUS_M = 6_371_008.8
51
+
52
+ /** Great-circle (haversine) distance in meters between two fixes. */
53
+ internal fun distanceMeters(a: TrackedLocation, b: TrackedLocation): Double =
54
+ distanceMeters(a.latitude, a.longitude, b.latitude, b.longitude)
55
+
56
+ /** Great-circle (haversine) distance in meters. */
57
+ internal fun distanceMeters(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
58
+ val phi1 = Math.toRadians(lat1)
59
+ val phi2 = Math.toRadians(lat2)
60
+ val dPhi = Math.toRadians(lat2 - lat1)
61
+ val dLambda = Math.toRadians(lng2 - lng1)
62
+ val h = sin(dPhi / 2) * sin(dPhi / 2) + cos(phi1) * cos(phi2) * sin(dLambda / 2) * sin(dLambda / 2)
63
+ return 2 * EARTH_RADIUS_M * asin(min(1.0, sqrt(h)))
64
+ }
65
+
66
+ /** Horizontal accuracy (m) of a fix; unknown (non-finite or negative) counts as 0. */
67
+ internal val TrackedLocation.accuracyMeters: Double
68
+ get() = accuracy.toDouble().takeIf { it.isFinite() && it > 0.0 } ?: 0.0
@@ -0,0 +1,74 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.config.GeolocationConfig
4
+ import com.brickssoft.locationtracking.config.TrackingMode
5
+ import com.brickssoft.locationtracking.model.DesiredAccuracy
6
+ import com.brickssoft.locationtracking.provider.LocationRequestSpec
7
+
8
+ /**
9
+ * The continuous location request the engine wants in each situation.
10
+ *
11
+ * Requests never ask the backend to filter by distance: the engine applies the (elastic) distance filter itself,
12
+ * and stop detection, polygon geofences and overdue timers need fixes to keep arriving while the device does not
13
+ * move.
14
+ */
15
+ internal object LocationRequests {
16
+ /**
17
+ * STATIONARY with the stationary region registered: PASSIVE updates, delivered at most every 30-60 s. A passive
18
+ * request makes the backend compute nothing; it only forwards fixes that other apps cause.
19
+ */
20
+ const val PASSIVE_INTERVAL_MS = 60_000L
21
+ const val PASSIVE_FASTEST_INTERVAL_MS = 30_000L
22
+
23
+ /**
24
+ * STATIONARY without the stationary region (registration failed, or no current anchor is known): one LOW-power
25
+ * fix (Wi-Fi / cell, no GPS) at most every 3 minutes.
26
+ */
27
+ const val FALLBACK_INTERVAL_MS = 180_000L
28
+
29
+ /**
30
+ * - LOCATION + MOVING, or [needsContinuousLocation] (inside a polygon geofence's enclosing circle):
31
+ * [moving].
32
+ * - LOCATION + STATIONARY: the [passive] request when the `passive` argument is true (the OS watches the
33
+ * stationary region, or the initial fix is still being computed by its own request), otherwise [fallback].
34
+ * - GEOFENCES otherwise: no request (null).
35
+ */
36
+ fun desired(
37
+ mode: TrackingMode,
38
+ isMoving: Boolean,
39
+ needsContinuousLocation: Boolean,
40
+ config: GeolocationConfig,
41
+ passive: Boolean,
42
+ ): LocationRequestSpec? = when {
43
+ mode == TrackingMode.LOCATION && isMoving -> moving(config)
44
+ needsContinuousLocation -> moving(config)
45
+ mode == TrackingMode.LOCATION -> if (passive) passive(config) else fallback(config)
46
+ else -> null
47
+ }
48
+
49
+ /** The configured accuracy and intervals. */
50
+ fun moving(config: GeolocationConfig): LocationRequestSpec = LocationRequestSpec(
51
+ accuracy = config.desiredAccuracy,
52
+ intervalMs = config.locationUpdateInterval.coerceAtLeast(0),
53
+ fastestIntervalMs = config.fastestLocationUpdateInterval.coerceAtLeast(0),
54
+ distanceFilterM = 0f,
55
+ )
56
+
57
+ /** PASSIVE: interval at least [PASSIVE_INTERVAL_MS], fastest interval at least [PASSIVE_FASTEST_INTERVAL_MS]. */
58
+ fun passive(config: GeolocationConfig): LocationRequestSpec {
59
+ val interval = maxOf(PASSIVE_INTERVAL_MS, config.locationUpdateInterval)
60
+ val fastest = minOf(interval, maxOf(PASSIVE_FASTEST_INTERVAL_MS, config.fastestLocationUpdateInterval))
61
+ return LocationRequestSpec(DesiredAccuracy.PASSIVE, interval, fastest, distanceFilterM = 0f)
62
+ }
63
+
64
+ /**
65
+ * LOW (or PASSIVE when that is the configured accuracy), interval and fastest interval at least
66
+ * [FALLBACK_INTERVAL_MS], so at most one fix per 3 minutes.
67
+ */
68
+ fun fallback(config: GeolocationConfig): LocationRequestSpec {
69
+ val accuracy =
70
+ if (config.desiredAccuracy == DesiredAccuracy.PASSIVE) DesiredAccuracy.PASSIVE else DesiredAccuracy.LOW
71
+ val interval = maxOf(FALLBACK_INTERVAL_MS, config.locationUpdateInterval)
72
+ return LocationRequestSpec(accuracy, interval, interval, distanceFilterM = 0f)
73
+ }
74
+ }
@@ -0,0 +1,291 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.config.Config
4
+ import com.brickssoft.locationtracking.model.ActivitySample
5
+ import com.brickssoft.locationtracking.model.ActivityType
6
+ import com.brickssoft.locationtracking.model.TrackedLocation
7
+ import kotlin.math.max
8
+
9
+ /** Motion state of the LOCATION tracking mode. */
10
+ internal enum class MotionState {
11
+ STATIONARY,
12
+ MOVING,
13
+ }
14
+
15
+ /**
16
+ * The config values the [MotionStateMachine] needs.
17
+ *
18
+ * @property stopTimeoutMs time without evidence of motion after which MOVING becomes STATIONARY (at least 1 min).
19
+ * @property trackingAccuracyThreshold `geolocation.filter.trackingAccuracyThreshold` (m): a STATIONARY fix with a
20
+ * worse accuracy never leaves STATIONARY by itself; 0 or less turns this check off.
21
+ */
22
+ internal data class MotionSettings(
23
+ val stationaryRadius: Double = 25.0,
24
+ val minimumConfidence: Int = 75,
25
+ val motionTriggerDelayMs: Long = 0,
26
+ val stopTimeoutMs: Long = 5 * MINUTE_MS,
27
+ val disableStopDetection: Boolean = false,
28
+ val trackingAccuracyThreshold: Double = 100.0,
29
+ ) {
30
+ companion object {
31
+ private const val MINUTE_MS = 60_000L
32
+
33
+ fun from(config: Config): MotionSettings = MotionSettings(
34
+ stationaryRadius = config.geolocation.stationaryRadius.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.0,
35
+ minimumConfidence = config.activity.minimumActivityRecognitionConfidence,
36
+ motionTriggerDelayMs = config.activity.motionTriggerDelay.coerceAtLeast(0),
37
+ // A zero timeout would flip back to STATIONARY right after every fix; one minute is the floor.
38
+ stopTimeoutMs = config.geolocation.stopTimeout.coerceAtLeast(1) * MINUTE_MS,
39
+ disableStopDetection = config.activity.disableStopDetection,
40
+ trackingAccuracyThreshold =
41
+ config.geolocation.filter.trackingAccuracyThreshold.takeIf { it.isFinite() } ?: 0.0,
42
+ )
43
+ }
44
+ }
45
+
46
+ /** What the engine must do after feeding an input to the [MotionStateMachine]. Actions are listed in order. */
47
+ internal sealed interface MotionAction {
48
+ /** The state became MOVING; [location] is the fix that triggered it, or null (activity, changePace). */
49
+ data class EnterMoving(val location: TrackedLocation?) : MotionAction
50
+
51
+ /** The state became STATIONARY at [location]; [automatic] is true for stop detection (not changePace). */
52
+ data class EnterStationary(val location: TrackedLocation?, val automatic: Boolean) : MotionAction
53
+
54
+ /** (Re)starts the stop-timeout timer; it must call [MotionStateMachine.onStopTimeout] when it fires. */
55
+ data class StartStopTimer(val delayMs: Long) : MotionAction
56
+
57
+ data object CancelStopTimer : MotionAction
58
+
59
+ /** Starts the motion-trigger timer; it must call [MotionStateMachine.onMotionTriggerElapsed] when it fires. */
60
+ data class StartMotionTrigger(val delayMs: Long) : MotionAction
61
+
62
+ data object CancelMotionTrigger : MotionAction
63
+ }
64
+
65
+ /**
66
+ * STATIONARY / MOVING state machine. Pure: no Android, no clock, no timers of its own; the engine runs the
67
+ * timers it asks for and reports back when they fire.
68
+ *
69
+ * - **STATIONARY** has an anchor (the first fix, or the location where the device stopped). It becomes MOVING
70
+ * when a fix is certainly outside the stationary radius (`distance(anchor, fix) - fix accuracy >
71
+ * stationaryRadius`) and its accuracy is at most `trackingAccuracyThreshold`, or when a moving activity
72
+ * (walking, running, on_foot, on_bicycle, in_vehicle) at or above the confidence threshold lasts for
73
+ * `motionTriggerDelay` (a confident `still` cancels the pending trigger). A fix that is certainly outside but
74
+ * coarser than the threshold changes nothing. A fix that is not certainly outside and is more accurate than the
75
+ * anchor replaces it. (The engine also leaves STATIONARY on the EXIT of its OS stationary region, through
76
+ * [force].)
77
+ * - **MOVING**: stop detection (unless disabled) keeps the stop-timeout timer running. Every piece of evidence
78
+ * of motion restarts it: a fix more than `max(stationaryRadius, accuracy)` from the last such fix, or a
79
+ * confident moving activity. A confident `still` or a fix without displacement only makes sure it is armed,
80
+ * so the device becomes STATIONARY `stopTimeout` after the last evidence of motion, even when no more
81
+ * fixes arrive.
82
+ */
83
+ internal class MotionStateMachine(settings: MotionSettings = MotionSettings()) {
84
+ var settings: MotionSettings = settings
85
+ private set
86
+
87
+ var state: MotionState = MotionState.STATIONARY
88
+ private set
89
+
90
+ val isMoving: Boolean get() = state == MotionState.MOVING
91
+
92
+ /** STATIONARY: the reference point of the stationary radius (null until the first fix). */
93
+ var anchor: TrackedLocation? = null
94
+ private set
95
+
96
+ /** MOVING: the last fix that showed displacement. */
97
+ var motionAnchor: TrackedLocation? = null
98
+ private set
99
+
100
+ var isStopTimerArmed: Boolean = false
101
+ private set
102
+
103
+ var isMotionTriggerArmed: Boolean = false
104
+ private set
105
+
106
+ /** Forgets everything and starts over in [state]. The caller cancels its timers. */
107
+ fun reset(state: MotionState = MotionState.STATIONARY, anchor: TrackedLocation? = null) {
108
+ this.state = state
109
+ this.anchor = if (state == MotionState.STATIONARY) anchor else null
110
+ motionAnchor = if (state == MotionState.MOVING) anchor else null
111
+ isStopTimerArmed = false
112
+ isMotionTriggerArmed = false
113
+ }
114
+
115
+ /** STATIONARY without an anchor: [location] becomes the anchor. */
116
+ fun offerAnchor(location: TrackedLocation?) {
117
+ if (state == MotionState.STATIONARY && anchor == null && location != null) anchor = location
118
+ }
119
+
120
+ /** STATIONARY: [location] becomes the anchor, replacing any earlier one (the fresh initial fix after start). */
121
+ fun setAnchor(location: TrackedLocation) {
122
+ if (state == MotionState.STATIONARY) anchor = location
123
+ }
124
+
125
+ /**
126
+ * Applies new settings. MOVING: may arm, cancel or restart the stop timer. STATIONARY: a pending motion
127
+ * trigger is cancelled when its delay changed, so the next moving activity starts over with the new delay.
128
+ */
129
+ fun updateSettings(newSettings: MotionSettings): List<MotionAction> {
130
+ val old = settings
131
+ settings = newSettings
132
+ if (state != MotionState.MOVING) {
133
+ if (!isMotionTriggerArmed || newSettings.motionTriggerDelayMs == old.motionTriggerDelayMs) return emptyList()
134
+ isMotionTriggerArmed = false
135
+ return listOf(MotionAction.CancelMotionTrigger)
136
+ }
137
+ return when {
138
+ newSettings.disableStopDetection -> cancelStopTimer()
139
+ !isStopTimerArmed || newSettings.stopTimeoutMs != old.stopTimeoutMs -> restartStopTimer()
140
+ else -> emptyList()
141
+ }
142
+ }
143
+
144
+ /** An accepted fix. */
145
+ fun onLocation(fix: TrackedLocation): List<MotionAction> = when (state) {
146
+ MotionState.STATIONARY -> {
147
+ val current = anchor
148
+ when {
149
+ current == null -> {
150
+ anchor = fix
151
+ emptyList()
152
+ }
153
+ isCertainlyOutside(current, fix) -> if (isAccurateEnough(fix)) enterMoving(fix) else emptyList()
154
+ else -> {
155
+ if (fix.accuracyMeters < current.accuracyMeters) anchor = fix
156
+ emptyList()
157
+ }
158
+ }
159
+ }
160
+ MotionState.MOVING -> {
161
+ val reference = motionAnchor
162
+ when {
163
+ reference == null -> {
164
+ motionAnchor = fix
165
+ ensureStopTimer()
166
+ }
167
+ distanceMeters(reference, fix) > max(settings.stationaryRadius, fix.accuracyMeters) -> {
168
+ motionAnchor = fix
169
+ restartStopTimer()
170
+ }
171
+ else -> ensureStopTimer()
172
+ }
173
+ }
174
+ }
175
+
176
+ /** An activity sample; samples below the confidence threshold are ignored. */
177
+ fun onActivity(sample: ActivitySample): List<MotionAction> {
178
+ if (sample.confidence < settings.minimumConfidence) return emptyList()
179
+ val moving = sample.type in MOVING_ACTIVITIES
180
+ val still = sample.type == ActivityType.STILL
181
+ return when (state) {
182
+ MotionState.STATIONARY -> when {
183
+ moving && settings.motionTriggerDelayMs <= 0 -> enterMoving(null)
184
+ moving && !isMotionTriggerArmed -> {
185
+ isMotionTriggerArmed = true
186
+ listOf(MotionAction.StartMotionTrigger(settings.motionTriggerDelayMs))
187
+ }
188
+ still && isMotionTriggerArmed -> {
189
+ isMotionTriggerArmed = false
190
+ listOf(MotionAction.CancelMotionTrigger)
191
+ }
192
+ else -> emptyList()
193
+ }
194
+ MotionState.MOVING -> when {
195
+ moving -> restartStopTimer()
196
+ still -> ensureStopTimer()
197
+ else -> emptyList()
198
+ }
199
+ }
200
+ }
201
+
202
+ /** The motion-trigger timer fired. */
203
+ fun onMotionTriggerElapsed(): List<MotionAction> {
204
+ val wasArmed = isMotionTriggerArmed
205
+ isMotionTriggerArmed = false
206
+ return if (wasArmed && state == MotionState.STATIONARY) enterMoving(null) else emptyList()
207
+ }
208
+
209
+ /** The stop-timeout timer fired; [location] (the best known fix) becomes the stationary anchor. */
210
+ fun onStopTimeout(location: TrackedLocation?): List<MotionAction> {
211
+ val wasArmed = isStopTimerArmed
212
+ isStopTimerArmed = false
213
+ return if (wasArmed && state == MotionState.MOVING) enterStationary(location, automatic = true) else emptyList()
214
+ }
215
+
216
+ /** `changePace`: forces a transition; [location] is the best known fix. */
217
+ fun force(isMoving: Boolean, location: TrackedLocation?): List<MotionAction> = when {
218
+ isMoving && state == MotionState.MOVING -> restartStopTimer()
219
+ isMoving -> enterMoving(location)
220
+ state == MotionState.MOVING -> enterStationary(location, automatic = false)
221
+ else -> emptyList()
222
+ }
223
+
224
+ /** True if [fix] lies outside the stationary radius around [anchor] even at the far edge of its accuracy. */
225
+ private fun isCertainlyOutside(anchor: TrackedLocation, fix: TrackedLocation): Boolean =
226
+ distanceMeters(anchor, fix) - fix.accuracyMeters > settings.stationaryRadius
227
+
228
+ /**
229
+ * False for a fix coarser than `trackingAccuracyThreshold`: such a fix never leaves STATIONARY by itself. (The
230
+ * engine's location processor already rejects such fixes; this keeps the rule of architecture round 2, §3 true
231
+ * for any processor.)
232
+ */
233
+ fun isAccurateEnough(fix: TrackedLocation): Boolean {
234
+ val threshold = settings.trackingAccuracyThreshold
235
+ return threshold <= 0.0 || fix.accuracyMeters <= threshold
236
+ }
237
+
238
+ private fun enterMoving(location: TrackedLocation?): List<MotionAction> {
239
+ val actions = ArrayList<MotionAction>(3)
240
+ if (isMotionTriggerArmed) {
241
+ isMotionTriggerArmed = false
242
+ actions += MotionAction.CancelMotionTrigger
243
+ }
244
+ state = MotionState.MOVING
245
+ anchor = null
246
+ motionAnchor = location
247
+ actions += MotionAction.EnterMoving(location)
248
+ actions += restartStopTimer()
249
+ return actions
250
+ }
251
+
252
+ private fun enterStationary(location: TrackedLocation?, automatic: Boolean): List<MotionAction> {
253
+ val actions = ArrayList<MotionAction>(3)
254
+ actions += cancelStopTimer()
255
+ if (isMotionTriggerArmed) {
256
+ isMotionTriggerArmed = false
257
+ actions += MotionAction.CancelMotionTrigger
258
+ }
259
+ state = MotionState.STATIONARY
260
+ motionAnchor = null
261
+ anchor = location
262
+ actions += MotionAction.EnterStationary(location, automatic)
263
+ return actions
264
+ }
265
+
266
+ private fun restartStopTimer(): List<MotionAction> {
267
+ if (settings.disableStopDetection) return emptyList()
268
+ isStopTimerArmed = true
269
+ return listOf(MotionAction.StartStopTimer(settings.stopTimeoutMs))
270
+ }
271
+
272
+ private fun ensureStopTimer(): List<MotionAction> =
273
+ if (isStopTimerArmed) emptyList() else restartStopTimer()
274
+
275
+ private fun cancelStopTimer(): List<MotionAction> {
276
+ if (!isStopTimerArmed) return emptyList()
277
+ isStopTimerArmed = false
278
+ return listOf(MotionAction.CancelStopTimer)
279
+ }
280
+
281
+ companion object {
282
+ /** Activities that count as motion. */
283
+ val MOVING_ACTIVITIES: Set<ActivityType> = setOf(
284
+ ActivityType.WALKING,
285
+ ActivityType.RUNNING,
286
+ ActivityType.ON_FOOT,
287
+ ActivityType.ON_BICYCLE,
288
+ ActivityType.IN_VEHICLE,
289
+ )
290
+ }
291
+ }
@@ -0,0 +1,41 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.core.Constants
4
+ import com.brickssoft.locationtracking.model.TrackedLocation
5
+ import com.brickssoft.locationtracking.provider.OsGeofence
6
+ import kotlin.math.max
7
+
8
+ /**
9
+ * The engine's own OS geofence while STATIONARY (architecture round 2, §3).
10
+ *
11
+ * While the device is STATIONARY the engine stops its own location requests (GPS off) and registers one circular
12
+ * geofence around the stationary anchor with the OS geofencing service of the selected backend. The OS reports the
13
+ * EXIT of that circle to `DefaultGeofenceManager`, which forwards it to the engine (`onStationaryRegionTransition`);
14
+ * the engine then switches to MOVING. The region is never stored, recorded or emitted as a user geofence.
15
+ */
16
+ internal object StationaryRegion {
17
+ /**
18
+ * Smallest radius of the region, in meters. OS geofencing (GMS, HMS, Android proximity alerts) works with
19
+ * Wi-Fi and cell locations whose error is often 50-150 m, so a smaller circle would report EXIT without movement.
20
+ */
21
+ const val MIN_RADIUS_M = 150.0
22
+
23
+ /** Region radius in meters: `max(stationaryRadius, MIN_RADIUS_M)`; a non-finite radius counts as 0. */
24
+ fun radius(stationaryRadius: Double): Float {
25
+ val configured = stationaryRadius.takeIf { it.isFinite() } ?: 0.0
26
+ return max(configured, MIN_RADIUS_M).toFloat()
27
+ }
28
+
29
+ /** The region around [anchor]: EXIT only, no initial trigger, id [Constants.STATIONARY_REGION_ID]. */
30
+ fun around(anchor: TrackedLocation, stationaryRadius: Double): OsGeofence = OsGeofence(
31
+ id = Constants.STATIONARY_REGION_ID,
32
+ latitude = anchor.latitude,
33
+ longitude = anchor.longitude,
34
+ radius = radius(stationaryRadius),
35
+ onEntry = false,
36
+ onExit = true,
37
+ onDwell = false,
38
+ loiteringDelayMs = 0,
39
+ initialTriggerEntry = false,
40
+ )
41
+ }
@@ -0,0 +1,60 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.config.State
4
+ import com.brickssoft.locationtracking.provider.ActivitySink
5
+ import com.brickssoft.locationtracking.provider.OsGeofenceTransition
6
+ import com.brickssoft.locationtracking.provider.StationaryRegionSink
7
+ import org.json.JSONObject
8
+
9
+ /** The tracking state machine. All state changes happen on `dispatchers.engine`. */
10
+ interface TrackingEngine : ActivitySink, StationaryRegionSink {
11
+ suspend fun ready(config: JSONObject?, reset: Boolean): State
12
+
13
+ suspend fun setConfig(config: JSONObject): State
14
+
15
+ suspend fun reset(config: JSONObject?): State
16
+
17
+ /** @throws com.brickssoft.locationtracking.core.TrackingException PERMISSION_DENIED if no foreground location. */
18
+ suspend fun start(): State
19
+
20
+ suspend fun startGeofences(): State
21
+
22
+ suspend fun stop(): State
23
+
24
+ suspend fun changePace(isMoving: Boolean)
25
+
26
+ fun state(): State
27
+
28
+ /** "restore" | "boot" | "package_replaced": cold process while enabled. */
29
+ suspend fun restore(reason: String)
30
+
31
+ /** Task removed. */
32
+ suspend fun onTerminate()
33
+
34
+ /**
35
+ * The foreground service could not enter the foreground after `ServiceController.start()` had returned true,
36
+ * for example a start from the background on Android 12+ without an exemption, or on Android 14+ with only
37
+ * while-in-use location permission. While tracking is enabled this stops it with the `tracking_stop` reason
38
+ * `service_start_failed`, so the server sees why the audit trail ends.
39
+ */
40
+ suspend fun onServiceStartFailed(error: String) = Unit
41
+
42
+ /**
43
+ * Tracking was enabled in a process that ended with a reboot or an app update, and `app.startOnBoot` is false, so
44
+ * it is not resumed. Records `tracking_stop` with [reason] (`reboot` | `package_replaced`), so the server learns why
45
+ * the heartbeats stopped, and clears `enabled`.
46
+ */
47
+ suspend fun endWithoutRestore(reason: String) = Unit
48
+
49
+ /**
50
+ * The resume notification was tapped and the service is in the foreground: resumes the session that Android refused
51
+ * to restore (`tracking_start` reason `resume_notification`), or stops the service if there is nothing to resume.
52
+ */
53
+ suspend fun resumeFromNotification() = Unit
54
+
55
+ /**
56
+ * A transition of the engine's stationary region (`Constants.STATIONARY_REGION_ID`), routed by the
57
+ * GeofenceManager. Round 2, unit 2 implements it (EXIT = the device left the stationary anchor). Default: ignored.
58
+ */
59
+ override suspend fun onStationaryRegionTransition(transition: OsGeofenceTransition) = Unit
60
+ }
@@ -0,0 +1,90 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.core.Clock
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.Job
6
+ import kotlinx.coroutines.delay
7
+ import kotlinx.coroutines.launch
8
+ import kotlinx.coroutines.sync.Mutex
9
+ import kotlinx.coroutines.sync.withLock
10
+ import java.util.EnumMap
11
+
12
+ /** The engine's timers. */
13
+ internal enum class TimerKind {
14
+ /** MOVING -> STATIONARY after `stopTimeout` without evidence of motion. */
15
+ STOP_TIMEOUT,
16
+
17
+ /** STATIONARY -> MOVING once a moving activity has lasted `motionTriggerDelay`. */
18
+ MOTION_TRIGGER,
19
+
20
+ /** `stopAfterElapsedMinutes`, measured from `trackingStartedAt`. */
21
+ STOP_AFTER_ELAPSED,
22
+ }
23
+
24
+ /**
25
+ * One-shot timers built on coroutines ([delay] in [scope]) and [Clock], without Handlers.
26
+ *
27
+ * Each timer has a deadline on the elapsed-realtime clock. A timer's action runs while holding [guard] (the
28
+ * engine's mutex), and only if the timer is still the current one of its kind, so a cancel or reschedule made
29
+ * under [guard] always wins over a timer that has already woken up. `delay()` does not advance while the CPU
30
+ * sleeps, so the engine also calls [fireDue] after every batch of fixes, every activity sample, every stationary-region
31
+ * exit and every heartbeat.
32
+ *
33
+ * Every method except the timer coroutines themselves must be called while holding [guard].
34
+ */
35
+ internal class TrackingTimers(
36
+ private val scope: CoroutineScope,
37
+ private val clock: Clock,
38
+ private val guard: Mutex,
39
+ ) {
40
+ private class Entry(val deadline: Long, val action: suspend () -> Unit) {
41
+ var job: Job? = null
42
+ }
43
+
44
+ private val entries = EnumMap<TimerKind, Entry>(TimerKind::class.java)
45
+
46
+ /** (Re)schedules [kind] to run [action] (while holding the guard) after [delayMs]; a negative delay fires ASAP. */
47
+ fun schedule(kind: TimerKind, delayMs: Long, action: suspend () -> Unit) {
48
+ cancel(kind)
49
+ val delayMillis = delayMs.coerceAtLeast(0)
50
+ val entry = Entry(clock.elapsedRealtime() + delayMillis, action)
51
+ entries[kind] = entry
52
+ entry.job = scope.launch {
53
+ delay(delayMillis)
54
+ guard.withLock { fireIfCurrent(kind, entry) }
55
+ }
56
+ }
57
+
58
+ fun cancel(kind: TimerKind) {
59
+ entries.remove(kind)?.job?.cancel()
60
+ }
61
+
62
+ fun cancelAll() {
63
+ val all = entries.values.toList()
64
+ entries.clear()
65
+ all.forEach { it.job?.cancel() }
66
+ }
67
+
68
+ fun isArmed(kind: TimerKind): Boolean = entries.containsKey(kind)
69
+
70
+ /** Milliseconds until [kind] fires (0 if overdue), or null if it is not armed. */
71
+ fun remainingMs(kind: TimerKind): Long? =
72
+ entries[kind]?.let { (it.deadline - clock.elapsedRealtime()).coerceAtLeast(0) }
73
+
74
+ /** Runs every timer whose deadline has passed, in [TimerKind] order. */
75
+ suspend fun fireDue() {
76
+ for (kind in TimerKind.entries) {
77
+ val entry = entries[kind] ?: continue
78
+ if (entry.deadline > clock.elapsedRealtime()) continue
79
+ entry.job?.cancel()
80
+ fireIfCurrent(kind, entry)
81
+ }
82
+ }
83
+
84
+ private suspend fun fireIfCurrent(kind: TimerKind, entry: Entry) {
85
+ if (entries[kind] !== entry) return
86
+ // Removed before running, so the action may cancel or reschedule timers (including this kind) freely.
87
+ entries.remove(kind)
88
+ entry.action()
89
+ }
90
+ }