@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.1

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 +107 -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 +60 -4
@@ -0,0 +1,1281 @@
1
+ package com.brickssoft.locationtracking.engine
2
+
3
+ import com.brickssoft.locationtracking.config.Config
4
+ import com.brickssoft.locationtracking.config.ConfigStore
5
+ import com.brickssoft.locationtracking.config.GeolocationConfig
6
+ import com.brickssoft.locationtracking.config.RuntimeState
7
+ import com.brickssoft.locationtracking.config.State
8
+ import com.brickssoft.locationtracking.config.TrackingMode
9
+ import com.brickssoft.locationtracking.core.Clock
10
+ import com.brickssoft.locationtracking.core.Constants
11
+ import com.brickssoft.locationtracking.core.ErrorCode
12
+ import com.brickssoft.locationtracking.core.EventBus
13
+ import com.brickssoft.locationtracking.core.ForceStopProbe
14
+ import com.brickssoft.locationtracking.core.Logger
15
+ import com.brickssoft.locationtracking.core.Subscription
16
+ import com.brickssoft.locationtracking.core.TrackingEvent
17
+ import com.brickssoft.locationtracking.core.TrackingException
18
+ import com.brickssoft.locationtracking.device.DeviceMonitor
19
+ import com.brickssoft.locationtracking.geofence.GeofenceManager
20
+ import com.brickssoft.locationtracking.heartbeat.HeartbeatScheduler
21
+ import com.brickssoft.locationtracking.http.HttpSyncer
22
+ import com.brickssoft.locationtracking.model.ActivitySample
23
+ import com.brickssoft.locationtracking.model.ActivityType
24
+ import com.brickssoft.locationtracking.model.GeofenceAction
25
+ import com.brickssoft.locationtracking.model.PermissionLevel
26
+ import com.brickssoft.locationtracking.model.ProviderState
27
+ import com.brickssoft.locationtracking.model.Record
28
+ import com.brickssoft.locationtracking.model.RecordEvent
29
+ import com.brickssoft.locationtracking.model.TrackedLocation
30
+ import com.brickssoft.locationtracking.permission.PermissionManager
31
+ import com.brickssoft.locationtracking.processing.FilterResult
32
+ import com.brickssoft.locationtracking.processing.LocationProcessor
33
+ import com.brickssoft.locationtracking.processing.Odometer
34
+ import com.brickssoft.locationtracking.processing.RecordFactory
35
+ import com.brickssoft.locationtracking.provider.ActivityBackend
36
+ import com.brickssoft.locationtracking.provider.GeofenceBackend
37
+ import com.brickssoft.locationtracking.provider.LocationBackend
38
+ import com.brickssoft.locationtracking.provider.LocationListener
39
+ import com.brickssoft.locationtracking.provider.LocationRequestSpec
40
+ import com.brickssoft.locationtracking.provider.OsGeofence
41
+ import com.brickssoft.locationtracking.provider.OsGeofenceTransition
42
+ import com.brickssoft.locationtracking.provider.ProviderFactory
43
+ import com.brickssoft.locationtracking.record.RecordSink
44
+ import com.brickssoft.locationtracking.service.ServiceController
45
+ import kotlinx.coroutines.CoroutineScope
46
+ import kotlinx.coroutines.Job
47
+ import kotlinx.coroutines.launch
48
+ import kotlinx.coroutines.sync.Mutex
49
+ import kotlinx.coroutines.sync.withLock
50
+ import kotlinx.coroutines.withContext
51
+ import kotlinx.coroutines.withTimeoutOrNull
52
+ import org.json.JSONObject
53
+ import kotlin.coroutines.ContinuationInterceptor
54
+ import kotlin.coroutines.CoroutineContext
55
+ import kotlin.coroutines.EmptyCoroutineContext
56
+ import kotlin.coroutines.cancellation.CancellationException
57
+
58
+ /**
59
+ * Default [TrackingEngine]: runs the tracking session (foreground service, location and activity backends,
60
+ * heartbeat, geofences), the STATIONARY / MOVING [MotionStateMachine], the location pipeline and the
61
+ * `tracking_start` / `tracking_stop` / `motionchange` records.
62
+ *
63
+ * Every state change runs on [scope]'s dispatcher (`dispatchers.engine`, single-threaded) while holding one
64
+ * mutex, so public calls, backend callbacks (any thread) and timers are serialized.
65
+ *
66
+ * Location pipeline, for each fix while tracking: `processor` -> (LOCATION mode) motion state machine ->
67
+ * `odometer` -> `geofences.onLocation` -> elastic distance check against the last recorded fix ->
68
+ * `recordFactory` -> `recordSink`. While STATIONARY a fix only feeds the state machine and the geofences
69
+ * (no odometer, no record), so GPS jitter while parked is neither recorded nor counted.
70
+ *
71
+ * Stationary GPS-off mode (architecture round 2, §3). While STATIONARY in the LOCATION mode the engine makes no
72
+ * location request that computes fixes: it registers the OS geofence [StationaryRegion] (id
73
+ * `Constants.STATIONARY_REGION_ID`, EXIT only) around the stationary anchor and requests PASSIVE updates, which only
74
+ * forward fixes that other apps cause. The foreground service and the heartbeat keep running. The heartbeat carries
75
+ * `runtime.lastLocation`, which is the anchor fix while STATIONARY (it changes only when the anchor changes, see
76
+ * [onStationaryFix]), so heartbeats show the anchor with the time it was acquired. STATIONARY ends (GPS on again,
77
+ * the configured request, a `motionchange` with `is_moving: true`) on the first of: the region's EXIT
78
+ * ([onStationaryRegionTransition]), a fix that is certainly outside the stationary radius (see
79
+ * [MotionStateMachine]), a confident moving activity lasting `motionTriggerDelay`, or `changePace(true)`. If the
80
+ * region cannot be registered (no background location permission, too many geofences, a backend error), or no
81
+ * current anchor is known (see [syncStationaryRegion]), a LOW-power request (at most one fix per 3 minutes) replaces
82
+ * the PASSIVE one. While `geofences.needsContinuousLocation` is true the configured request is used, as before.
83
+ */
84
+ class DefaultTrackingEngine(
85
+ private val configStore: ConfigStore,
86
+ private val providers: ProviderFactory,
87
+ private val processor: LocationProcessor,
88
+ private val odometer: Odometer,
89
+ private val recordFactory: RecordFactory,
90
+ private val recordSink: RecordSink,
91
+ private val heartbeat: HeartbeatScheduler,
92
+ private val geofences: GeofenceManager,
93
+ private val serviceController: ServiceController,
94
+ private val device: DeviceMonitor,
95
+ private val syncer: HttpSyncer,
96
+ private val permissions: PermissionManager,
97
+ private val events: EventBus,
98
+ private val clock: Clock,
99
+ private val scope: CoroutineScope,
100
+ /** Background triggers do not restore tracking in a process started after a user force stop. */
101
+ private val forceStopProbe: ForceStopProbe = ForceStopProbe.NEVER,
102
+ ) : TrackingEngine {
103
+ private val mutex = Mutex()
104
+ private val engineContext: CoroutineContext =
105
+ scope.coroutineContext[ContinuationInterceptor] ?: EmptyCoroutineContext
106
+ private val timers = TrackingTimers(scope, clock, mutex)
107
+
108
+ /** The tracking session running in this process, or null. Guarded by [mutex]. */
109
+ private var session: Session? = null
110
+ private var nextSessionId = 1L
111
+
112
+ private val config: Config get() = configStore.config.value
113
+ private val runtime: RuntimeState get() = configStore.runtime.value
114
+
115
+ // ------------------------------------------------------------------ config
116
+
117
+ override suspend fun ready(config: JSONObject?, reset: Boolean): State = serialized {
118
+ val old = this.config
119
+ val new = configStore.ready(config, reset)
120
+ configStore.updateRuntime { it.copy(didReady = true) }
121
+ onConfigChanged(old, new)
122
+ if (session == null && runtime.enabled) {
123
+ Logger.i(TAG, "ready: tracking is enabled but not running in this process; restoring")
124
+ restoreLocked(REASON_RESTORE)
125
+ }
126
+ state()
127
+ }
128
+
129
+ override suspend fun setConfig(config: JSONObject): State = serialized {
130
+ val old = this.config
131
+ onConfigChanged(old, configStore.merge(config))
132
+ state()
133
+ }
134
+
135
+ override suspend fun reset(config: JSONObject?): State = serialized {
136
+ val old = this.config
137
+ onConfigChanged(old, configStore.reset(config))
138
+ state()
139
+ }
140
+
141
+ // ------------------------------------------------------------------ tracking
142
+
143
+ override suspend fun start(): State = serialized {
144
+ startLocked(TrackingMode.LOCATION)
145
+ state()
146
+ }
147
+
148
+ override suspend fun startGeofences(): State = serialized {
149
+ startLocked(TrackingMode.GEOFENCES)
150
+ state()
151
+ }
152
+
153
+ override suspend fun stop(): State = serialized {
154
+ // A paused session must not be resumed after the app stopped tracking (for example on logout).
155
+ guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
156
+ stopLocked(REASON_STOP)
157
+ state()
158
+ }
159
+
160
+ override suspend fun changePace(isMoving: Boolean) = serialized {
161
+ val s = session
162
+ when {
163
+ s == null -> Logger.i(TAG, "changePace($isMoving) ignored: tracking is not enabled")
164
+ s.mode != TrackingMode.LOCATION -> Logger.i(TAG, "changePace($isMoving) ignored in geofences mode")
165
+ else -> {
166
+ Logger.i(TAG, "changePace($isMoving)")
167
+ val location = if (isMoving != s.motion.isMoving) bestKnownLocation(s) else null
168
+ execute(s, s.motion.force(isMoving, location))
169
+ }
170
+ }
171
+ }
172
+
173
+ override fun state(): State = State(configStore.config.value, configStore.runtime.value, providers.kind)
174
+
175
+ override suspend fun restore(reason: String) = serialized { restoreLocked(reason) }
176
+
177
+ override suspend fun endWithoutRestore(reason: String) = serialized {
178
+ if (session != null || !runtime.enabled) return@serialized
179
+ Logger.i(TAG, "tracking is not resumed after $reason (app.startOnBoot is false)")
180
+ stopLocked(reason)
181
+ }
182
+
183
+ /**
184
+ * The `tracking_stop` reason is `permission_denied` when foreground location permission is no longer granted
185
+ * (on Android 14+ the system's START_STICKY restart after a revoked permission fails in `startForeground`),
186
+ * otherwise `service_start_failed`.
187
+ */
188
+ override suspend fun onServiceStartFailed(error: String) = serialized {
189
+ if (session == null && !runtime.enabled) return@serialized
190
+ val reason = if (permissions.hasForegroundLocation()) REASON_SERVICE_START_FAILED else REASON_PERMISSION_DENIED
191
+ Logger.w(TAG, "the foreground service failed to start ($error); stopping tracking with reason $reason")
192
+ stopLocked(reason)
193
+ if (reason == REASON_SERVICE_START_FAILED) offerResume()
194
+ }
195
+
196
+ override suspend fun resumeFromNotification() = serialized {
197
+ guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
198
+ val deadline = elapsedStopAt()
199
+ when {
200
+ session != null -> Logger.i(TAG, "resume notification: tracking already runs")
201
+ runtime.enabled -> restoreLocked(REASON_RESUME_NOTIFICATION)
202
+ !config.notification.resume.enabled -> stopResumeService("notification.resume is disabled")
203
+ deadline != null && deadline <= clock.now() -> stopResumeService("stopAfterElapsedMinutes has passed")
204
+ !permissions.hasForegroundLocation() -> stopResumeService("location permission is not granted")
205
+ else -> {
206
+ Logger.i(TAG, "resume notification: resuming ${runtime.trackingMode.wire} tracking")
207
+ configStore.updateRuntime { it.copy(enabled = true) }
208
+ restoreLocked(REASON_RESUME_NOTIFICATION)
209
+ if (session != null) events.emit(TrackingEvent.EnabledChange(true))
210
+ }
211
+ }
212
+ }
213
+
214
+ private fun stopResumeService(why: String) {
215
+ Logger.i(TAG, "resume notification: not resuming ($why)")
216
+ guarded("serviceController.stop") { serviceController.stop() }
217
+ }
218
+
219
+ /**
220
+ * After Android refused to restore tracking from the background: posts the resume notification if
221
+ * `notification.resume.enabled`, unless the session's `stopAfterElapsedMinutes` has already passed.
222
+ */
223
+ private fun offerResume() {
224
+ if (!config.notification.resume.enabled) return
225
+ val deadline = elapsedStopAt()
226
+ if (deadline != null && deadline <= clock.now()) {
227
+ Logger.i(TAG, "no resume notification: stopAfterElapsedMinutes has passed")
228
+ return
229
+ }
230
+ guarded("serviceController.showResumeNotification") { serviceController.showResumeNotification(deadline) }
231
+ }
232
+
233
+ /** When `stopAfterElapsedMinutes` ends the session started at `trackingStartedAt`; null without a limit. */
234
+ private fun elapsedStopAt(): Long? {
235
+ val minutes = config.geolocation.stopAfterElapsedMinutes
236
+ val startedAt = runtime.trackingStartedAt ?: return null
237
+ return if (minutes > 0) startedAt + minutes * MINUTE_MS else null
238
+ }
239
+
240
+ override suspend fun onTerminate() = serialized {
241
+ when {
242
+ !config.app.stopOnTerminate -> Logger.i(TAG, "task removed: stopOnTerminate is false; tracking continues")
243
+ session != null || runtime.enabled -> {
244
+ Logger.i(TAG, "task removed: stopOnTerminate is true; stopping")
245
+ stopLocked(REASON_TERMINATE)
246
+ }
247
+ else -> Unit
248
+ }
249
+ }
250
+
251
+ // ------------------------------------------------------------------ ActivitySink
252
+
253
+ override suspend fun onActivitySamples(samples: List<ActivitySample>) {
254
+ if (samples.isEmpty()) return
255
+ serialized { handleActivity(samples) }
256
+ }
257
+
258
+ // ------------------------------------------------------------------ StationaryRegionSink
259
+
260
+ override suspend fun onStationaryRegionTransition(transition: OsGeofenceTransition) = serialized {
261
+ handleStationaryRegionTransition(transition)
262
+ }
263
+
264
+ // ------------------------------------------------------------------ start / stop / restore
265
+
266
+ private suspend fun startLocked(mode: TrackingMode) {
267
+ val reason = if (mode == TrackingMode.LOCATION) REASON_START else REASON_START_GEOFENCES
268
+ val current = session
269
+ if (current != null) {
270
+ if (current.mode == mode) {
271
+ Logger.d(TAG, "${mode.wire} tracking is already running")
272
+ } else {
273
+ switchMode(current, mode, reason)
274
+ }
275
+ return
276
+ }
277
+ if (!permissions.hasForegroundLocation()) {
278
+ throw TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted")
279
+ }
280
+ if (mode == TrackingMode.GEOFENCES && !permissions.hasBackgroundLocation()) {
281
+ Logger.w(TAG, "startGeofences without background location permission: geofences may not fire in background")
282
+ }
283
+ val wasEnabled = runtime.enabled
284
+ val startedAt = clock.now()
285
+ configStore.updateRuntime {
286
+ it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
287
+ }
288
+ if (!startService()) {
289
+ // Foreground location is granted (checked above): Android refused the foreground service, for example a
290
+ // start from the background, or Android 14+ with only while-in-use location permission. The error code
291
+ // stays PERMISSION_DENIED for the JS API; the audit record gives the precise reason.
292
+ configStore.updateRuntime { it.copy(enabled = false) }
293
+ if (wasEnabled) releaseOrphanedRegistrations()
294
+ submit(RecordEvent.TRACKING_STOP, bestKnownLocation(null), reason = REASON_SERVICE_START_FAILED)
295
+ throw TrackingException(
296
+ ErrorCode.PERMISSION_DENIED,
297
+ "The location foreground service could not be started (Android refused a start from the background)",
298
+ )
299
+ }
300
+ // A process that died while tracking may have left its stationary region with the OS.
301
+ val s = activate(mode, reason, leftoverRegion = wasEnabled)
302
+ events.emit(TrackingEvent.EnabledChange(true))
303
+ if (mode == TrackingMode.LOCATION) launchInitialFix(s)
304
+ }
305
+
306
+ /**
307
+ * Cold process with `runtime.enabled`: restarts everything in the persisted mode, without permission prompts. If the
308
+ * session's `stopAfterElapsedMinutes` deadline has passed, it records that stop instead and starts nothing.
309
+ */
310
+ private suspend fun restoreLocked(reason: String) {
311
+ if (!runtime.enabled) {
312
+ Logger.i(TAG, "restore($reason): tracking is not enabled; nothing to restore")
313
+ return
314
+ }
315
+ if (session != null) {
316
+ if (!serviceController.isRunning) {
317
+ Logger.w(TAG, "restore($reason): tracking runs but the foreground service does not; restarting it")
318
+ if (!startService()) {
319
+ Logger.w(TAG, "restore($reason): the foreground service was refused; stopping tracking")
320
+ stopLocked(REASON_SERVICE_START_FAILED)
321
+ offerResume()
322
+ }
323
+ }
324
+ return
325
+ }
326
+ // The session ended while no process ran its timer (the phone was off, or the app was killed or updated).
327
+ val deadline = elapsedStopAt()
328
+ if (deadline != null && deadline <= clock.now()) {
329
+ Logger.i(TAG, "restore($reason): stopAfterElapsedMinutes passed while tracking was not running; stopping")
330
+ stopLocked(REASON_STOP_AFTER_ELAPSED)
331
+ return
332
+ }
333
+ if (!permissions.hasForegroundLocation()) {
334
+ Logger.w(TAG, "restore($reason): location permission is no longer granted; stopping")
335
+ stopLocked(REASON_PERMISSION_DENIED)
336
+ return
337
+ }
338
+ val mode = runtime.trackingMode
339
+ val startedAt = runtime.trackingStartedAt ?: clock.now()
340
+ configStore.updateRuntime {
341
+ it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
342
+ }
343
+ // The system may have restarted the service itself (START_STICKY), which then calls restore(): starting it again
344
+ // from the background could be refused on Android 12+ although it is already in the foreground.
345
+ if (!serviceController.isRunning && !startService()) {
346
+ // Without the foreground service Android throttles background location and the heartbeat alarms would
347
+ // keep retrying; end the session with an explicit audit record instead.
348
+ Logger.w(TAG, "restore($reason): the foreground service was refused; stopping tracking")
349
+ stopLocked(REASON_SERVICE_START_FAILED)
350
+ offerResume()
351
+ return
352
+ }
353
+ // GMS and HMS keep geofences across a process death: the stationary region of the dead process may remain.
354
+ val s = activate(mode, reason, leftoverRegion = true)
355
+ if (mode == TrackingMode.LOCATION) launchInitialFix(s)
356
+ }
357
+
358
+ /**
359
+ * Starts a new session: components, backends, location request, then the `tracking_start` record.
360
+ * [leftoverRegion]: an earlier process may have left the stationary region registered with the OS.
361
+ */
362
+ private suspend fun activate(mode: TrackingMode, reason: String, leftoverRegion: Boolean): Session {
363
+ val s = Session(nextSessionId++, mode, MotionSettings.from(config))
364
+ s.leftoverRegion = leftoverRegion
365
+ session = s
366
+ guarded("processor.reset") { processor.reset() }
367
+ guarded("device.start") { device.start() }
368
+ guarded("syncer.start") { syncer.start() }
369
+ guarded("heartbeat.start") { heartbeat.start() }
370
+ guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
371
+ configureMode(s)
372
+ watchGeofenceDemand(s)
373
+ s.eventSubscription = events.subscribe { event -> onEvent(s, event) }
374
+ guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
375
+ submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
376
+ Logger.i(TAG, "tracking started: session=${s.id} mode=${mode.wire} reason=$reason backend=${providers.kind.wire}")
377
+ return s
378
+ }
379
+
380
+ /** `start()` while `startGeofences()` runs, or the reverse. */
381
+ private suspend fun switchMode(s: Session, mode: TrackingMode, reason: String) {
382
+ if (!permissions.hasForegroundLocation()) {
383
+ throw TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted")
384
+ }
385
+ Logger.i(TAG, "switching tracking mode ${s.mode.wire} -> ${mode.wire}")
386
+ s.initialFixJob?.cancel()
387
+ if (s.mode == TrackingMode.LOCATION && s.motion.isMoving) {
388
+ // Motion tracking ends with the location mode.
389
+ recordMotionChange(s, isMoving = false, bestKnownLocation(s))
390
+ }
391
+ s.mode = mode
392
+ val startedAt = clock.now()
393
+ configStore.updateRuntime { it.copy(trackingMode = mode, isMoving = false, trackingStartedAt = startedAt) }
394
+ guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
395
+ configureMode(s)
396
+ submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
397
+ if (mode == TrackingMode.LOCATION) launchInitialFix(s)
398
+ }
399
+
400
+ /**
401
+ * Resets the motion state to STATIONARY (without an anchor until the initial fix) and applies the mode's activity
402
+ * updates, stationary region, request and timers.
403
+ */
404
+ private suspend fun configureMode(s: Session) {
405
+ timers.cancel(TimerKind.STOP_TIMEOUT)
406
+ timers.cancel(TimerKind.MOTION_TRIGGER)
407
+ s.motion.reset()
408
+ s.motionRecorded = false
409
+ s.lastRecorded = null
410
+ s.lastActivityType = null
411
+ if (s.mode == TrackingMode.LOCATION) startActivityUpdates(s) else stopActivityUpdates(s)
412
+ s.needsContinuousLocation = geofences.needsContinuousLocation.value
413
+ applyMotionRequests(s)
414
+ scheduleElapsedStop(s)
415
+ }
416
+
417
+ /**
418
+ * Stops tracking: data sources first, then the `tracking_stop` record (before the service stops, so it is
419
+ * queued and its upload starts while the process still has foreground priority), then the service,
420
+ * heartbeat and geofences. Without a session (cold process) it releases what the previous process left
421
+ * registered with the OS.
422
+ */
423
+ private suspend fun stopLocked(reason: String) {
424
+ val s = session
425
+ if (s == null && !runtime.enabled) {
426
+ Logger.d(TAG, "stop($reason): tracking is not enabled")
427
+ return
428
+ }
429
+ session = null
430
+ timers.cancelAll()
431
+ if (s != null) {
432
+ s.initialFixJob?.cancel()
433
+ s.geofenceWatchJob?.cancel()
434
+ s.eventSubscription?.cancel()
435
+ removeLocationUpdates(s)
436
+ stopActivityUpdates(s)
437
+ } else {
438
+ stopActivityBackend()
439
+ }
440
+ submit(RecordEvent.TRACKING_STOP, bestKnownLocation(s), reason = reason)
441
+ // After the record, so a slow OS call cannot delay it. Without a session (cold process): GMS and HMS keep
442
+ // geofences across a process death, so the previous process may have left the region.
443
+ if (s != null) removeStationaryRegion(s) else removeStationaryRegionFrom(null)
444
+ guarded("serviceController.stop") { serviceController.stop() }
445
+ guarded("heartbeat.stop") { heartbeat.stop() }
446
+ guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
447
+ configStore.updateRuntime { it.copy(enabled = false, isMoving = false) }
448
+ events.emit(TrackingEvent.EnabledChange(false))
449
+ Logger.i(TAG, "tracking stopped: reason=$reason")
450
+ }
451
+
452
+ /** A refused start in a cold process: drop the previous process's activity, alarm and geofence registrations. */
453
+ /**
454
+ * A background trigger ([trigger]) woke a process in which tracking is enabled but not running. Restores tracking,
455
+ * except in a process that started after a user force stop: then it releases the leftover OS registrations (so they
456
+ * stop waking the app) and leaves `enabled` set, so opening the app (`ready()`) restores tracking. Returns whether
457
+ * a session runs afterwards.
458
+ */
459
+ private suspend fun restoreFromBackgroundTrigger(trigger: String): Boolean {
460
+ if (forceStopProbe.startedAfterForceStop()) {
461
+ Logger.i(TAG, "$trigger after a force stop: tracking stays off until the app is opened")
462
+ releaseOrphanedRegistrations()
463
+ return false
464
+ }
465
+ Logger.i(TAG, "$trigger in a process where tracking is enabled but not running; restoring")
466
+ restoreLocked(REASON_RESTORE)
467
+ return session != null
468
+ }
469
+
470
+ private suspend fun releaseOrphanedRegistrations() {
471
+ stopActivityBackend()
472
+ removeStationaryRegionFrom(null)
473
+ guarded("heartbeat.stop") { heartbeat.stop() }
474
+ guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
475
+ }
476
+
477
+ private fun startService(): Boolean = try {
478
+ serviceController.start()
479
+ } catch (e: Exception) {
480
+ Logger.e(TAG, "foreground service start failed", e)
481
+ false
482
+ }
483
+
484
+ // ------------------------------------------------------------------ config changes
485
+
486
+ private suspend fun onConfigChanged(old: Config, new: Config) {
487
+ if (old == new) return
488
+ val s = session
489
+ val providerChanged = old.locationProvider != new.locationProvider
490
+ if (providerChanged) switchProvider(s, old, new)
491
+ if (old.notification.resume.enabled && !new.notification.resume.enabled) {
492
+ guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
493
+ }
494
+ if (s == null) return
495
+ if (!providerChanged && activitySettingsChanged(old, new)) {
496
+ stopActivityUpdates(s)
497
+ startActivityUpdates(s)
498
+ }
499
+ execute(s, s.motion.updateSettings(MotionSettings.from(new)))
500
+ if (session !== s) return
501
+ applyMotionRequests(s)
502
+ if (old.geolocation.stopAfterElapsedMinutes != new.geolocation.stopAfterElapsedMinutes) scheduleElapsedStop(s)
503
+ if (old.notification != new.notification) {
504
+ guarded("serviceController.refreshNotification") { serviceController.refreshNotification() }
505
+ }
506
+ }
507
+
508
+ private fun activitySettingsChanged(old: Config, new: Config): Boolean =
509
+ old.activity.disableMotionActivityUpdates != new.activity.disableMotionActivityUpdates ||
510
+ old.activity.activityRecognitionInterval != new.activity.activityRecognitionInterval
511
+
512
+ /**
513
+ * `locationProvider` changed: re-selects the backend. While tracking, listeners, activity updates, the
514
+ * stationary region and geofences are removed from the current backend first and registered again on the
515
+ * selected one.
516
+ */
517
+ private suspend fun switchProvider(s: Session?, old: Config, new: Config) {
518
+ if (s != null) {
519
+ removeLocationUpdates(s)
520
+ stopActivityUpdates(s)
521
+ removeStationaryRegion(s)
522
+ guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
523
+ }
524
+ val changed = try {
525
+ providers.reselect()
526
+ } catch (e: Exception) {
527
+ Logger.e(TAG, "provider reselection failed", e)
528
+ false
529
+ }
530
+ Logger.i(
531
+ TAG,
532
+ "locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}: " +
533
+ "backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}",
534
+ )
535
+ if (s != null) {
536
+ guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
537
+ startActivityUpdates(s)
538
+ // The region is registered on the selected backend, also after a failure on the previous one.
539
+ applyMotionRequests(s, force = true)
540
+ }
541
+ if (changed) guarded("device.checkProviderState") { device.checkProviderState(PROVIDER_CHECK_REASON) }
542
+ }
543
+
544
+ // ------------------------------------------------------------------ backends
545
+
546
+ /**
547
+ * Brings the location request and the stationary region in line with the motion state. When the configured
548
+ * (MOVING) request is wanted it is applied first, so GPS is on before the OS call that removes the region.
549
+ * [force] and [checkLimit]: see [syncStationaryRegion].
550
+ */
551
+ private suspend fun applyMotionRequests(s: Session, force: Boolean = false, checkLimit: Boolean = false) {
552
+ if (!isStationary(s)) applyLocationRequest(s)
553
+ syncStationaryRegion(s, force, checkLimit)
554
+ applyLocationRequest(s)
555
+ }
556
+
557
+ /** STATIONARY in the LOCATION mode: the state in which the stationary region is wanted. */
558
+ private fun isStationary(s: Session): Boolean =
559
+ session === s && s.mode == TrackingMode.LOCATION && !s.motion.isMoving
560
+
561
+ private fun applyLocationRequest(s: Session) {
562
+ val spec = LocationRequests.desired(
563
+ s.mode,
564
+ s.motion.isMoving,
565
+ s.needsContinuousLocation,
566
+ config.geolocation,
567
+ // PASSIVE while the OS watches the region, and while the initial fix (its own request) is pending.
568
+ passive = s.region != null || !s.motionRecorded,
569
+ )
570
+ val backend = providers.location()
571
+ val current = s.locationBackend
572
+ if (spec == s.appliedSpec && (spec == null || current === backend)) return
573
+ if (current != null && (spec == null || current !== backend)) removeLocationUpdates(s)
574
+ if (spec == null) {
575
+ Logger.d(TAG, "location request: none")
576
+ return
577
+ }
578
+ try {
579
+ backend.requestUpdates(spec, s.listener)
580
+ s.locationBackend = backend
581
+ s.appliedSpec = spec
582
+ Logger.d(TAG, "location request: $spec on ${backend.kind.wire}")
583
+ } catch (e: Exception) {
584
+ Logger.e(TAG, "location request failed", e)
585
+ }
586
+ }
587
+
588
+ private fun removeLocationUpdates(s: Session) {
589
+ val backend = s.locationBackend ?: return
590
+ s.locationBackend = null
591
+ s.appliedSpec = null
592
+ guarded("removeUpdates") { backend.removeUpdates(s.listener) }
593
+ }
594
+
595
+ /**
596
+ * Registers the stationary region while the session is STATIONARY in the LOCATION mode, and removes it otherwise.
597
+ *
598
+ * The region is centred on the motion anchor and is registered only when:
599
+ * - the initial `motionchange` after start / restore has been recorded (so the fresh initial fix is the centre);
600
+ * - the anchor was current when it became the anchor (see [isAnchorConfirmed]): with `initialTriggerEntry =
601
+ * false` the OS never reports EXIT for a device that is already outside, so a region around an old fix could
602
+ * leave the device STATIONARY with GPS off after it moved away;
603
+ * - it is not up to date already: registered on the current backend with the configured radius and a centre at
604
+ * most [REGION_RECENTER_M] from the anchor. [force] registers it again anyway (location services came back,
605
+ * and GMS / HMS may have dropped it);
606
+ * - no earlier registration of this stationary period failed (not retried until [force] or leaving STATIONARY);
607
+ * - the user geofences leave the OS a free slot (see [userGeofenceSlotsFull]; checked before registering and,
608
+ * with [checkLimit], for a registered region, which is then removed).
609
+ * Otherwise STATIONARY uses the low-power fallback request. Never throws, except cancellation.
610
+ */
611
+ private suspend fun syncStationaryRegion(s: Session, force: Boolean = false, checkLimit: Boolean = false) {
612
+ if (!isStationary(s)) {
613
+ removeStationaryRegion(s)
614
+ s.regionFailed = false
615
+ return
616
+ }
617
+ if (!s.motionRecorded) return
618
+ val anchor = s.motion.anchor ?: return
619
+ if (checkLimit && s.region != null && userGeofenceSlotsFull()) {
620
+ Logger.w(TAG, "the user geofences need every OS geofence slot: removing the stationary region")
621
+ removeStationaryRegion(s)
622
+ return
623
+ }
624
+ val backend = try {
625
+ providers.geofence()
626
+ } catch (e: CancellationException) {
627
+ throw e
628
+ } catch (e: Exception) {
629
+ Logger.w(TAG, "no geofence backend for the stationary region; using the low-power request instead", e)
630
+ s.region = null
631
+ s.regionFailed = true
632
+ return
633
+ }
634
+ val radius = StationaryRegion.radius(config.geolocation.stationaryRadius)
635
+ val registered = s.region
636
+ val upToDate = registered != null &&
637
+ registered.radius == radius &&
638
+ s.regionBackend?.kind == backend.kind &&
639
+ distanceMeters(registered.latitude, registered.longitude, anchor.latitude, anchor.longitude) <=
640
+ REGION_RECENTER_M
641
+ if (upToDate && !force) return
642
+ if (s.regionFailed && !force) return
643
+ if (!isAnchorConfirmed(s, anchor)) {
644
+ Logger.i(TAG, "stationary anchor is ${ageMs(anchor) / 1000} s old: low-power requests until a current fix")
645
+ return
646
+ }
647
+ if (userGeofenceSlotsFull()) {
648
+ Logger.w(TAG, "the user geofences need every OS geofence slot: no stationary region, low-power requests")
649
+ removeStationaryRegion(s)
650
+ return
651
+ }
652
+ if (s.regionBackend?.let { it.kind != backend.kind } == true) removeStationaryRegion(s)
653
+ registerStationaryRegion(s, backend, StationaryRegion.around(anchor, config.geolocation.stationaryRadius))
654
+ }
655
+
656
+ /**
657
+ * True if [anchor] was current (at most [ANCHOR_MAX_AGE_MS] old) when it became the anchor. The device is
658
+ * believed not to have moved since, so a confirmed anchor stays confirmed for the whole stationary period.
659
+ */
660
+ private fun isAnchorConfirmed(s: Session, anchor: TrackedLocation): Boolean {
661
+ if (anchor === s.confirmedAnchor) return true
662
+ if (!isCurrent(anchor)) return false
663
+ s.confirmedAnchor = anchor
664
+ return true
665
+ }
666
+
667
+ /** At most [ANCHOR_MAX_AGE_MS] old by the wall clock (a fix from the future counts as current). */
668
+ private fun isCurrent(fix: TrackedLocation): Boolean = ageMs(fix) <= ANCHOR_MAX_AGE_MS
669
+
670
+ private fun ageMs(fix: TrackedLocation): Long = clock.now() - fix.time
671
+
672
+ /**
673
+ * True when the stored user geofences need every OS slot but one ([Constants.MAX_GEOFENCES] - 1 or more): GMS
674
+ * accepts at most 100 geofences per app, and the stationary region must not make the user's last `add` fail.
675
+ */
676
+ private suspend fun userGeofenceSlotsFull(): Boolean = try {
677
+ geofences.list().size >= Constants.MAX_GEOFENCES - 1
678
+ } catch (e: CancellationException) {
679
+ throw e
680
+ } catch (e: Exception) {
681
+ Logger.w(TAG, "cannot count the user geofences", e)
682
+ false
683
+ }
684
+
685
+ /** Adds [region] to [backend] (adding the same id replaces it); on failure the low-power fallback applies. */
686
+ private suspend fun registerStationaryRegion(s: Session, backend: GeofenceBackend, region: OsGeofence) {
687
+ // Remembered before the call: after a timeout the OS may still complete the registration.
688
+ s.regionBackend = backend
689
+ s.leftoverRegion = false // the same id: a region left by an earlier process is replaced
690
+ val failure: Exception? = try {
691
+ val done = withTimeoutOrNull(OS_GEOFENCE_TIMEOUT_MS) { backend.add(listOf(region)) }
692
+ if (done != null) {
693
+ null
694
+ } else {
695
+ TrackingException(ErrorCode.TIMEOUT, "no answer within $OS_GEOFENCE_TIMEOUT_MS ms")
696
+ }
697
+ } catch (e: CancellationException) {
698
+ throw e
699
+ } catch (e: Exception) {
700
+ e
701
+ }
702
+ if (failure == null) {
703
+ s.region = region
704
+ s.regionFailed = false
705
+ Logger.i(
706
+ TAG,
707
+ "stationary region registered on ${backend.kind.wire}: radius ${region.radius} m around " +
708
+ "(${region.latitude}, ${region.longitude}); GPS off, passive location updates",
709
+ )
710
+ } else {
711
+ s.region = null
712
+ s.regionFailed = true
713
+ Logger.w(
714
+ TAG,
715
+ "stationary region could not be registered on ${backend.kind.wire} (${failure.message}); " +
716
+ "using a low-power request (at most one fix per 3 minutes) instead of passive updates",
717
+ failure,
718
+ )
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Removes the stationary region this session registered (or tried to register), or one an earlier process may
724
+ * have left ([Session.leftoverRegion]), from the OS.
725
+ */
726
+ private suspend fun removeStationaryRegion(s: Session) {
727
+ val backend = s.regionBackend
728
+ if (backend == null && !s.leftoverRegion) return
729
+ s.regionBackend = null
730
+ s.region = null
731
+ s.leftoverRegion = false
732
+ removeStationaryRegionFrom(backend)
733
+ }
734
+
735
+ /**
736
+ * Removes [Constants.STATIONARY_REGION_ID] from [backend] (null: the current backend). Removing an id that is not
737
+ * registered is harmless. Failures are logged, never thrown (except cancellation).
738
+ */
739
+ private suspend fun removeStationaryRegionFrom(backend: GeofenceBackend?) {
740
+ try {
741
+ val target = backend ?: providers.geofence()
742
+ val done = withTimeoutOrNull(OS_GEOFENCE_TIMEOUT_MS) {
743
+ target.remove(listOf(Constants.STATIONARY_REGION_ID))
744
+ }
745
+ if (done == null) {
746
+ Logger.w(TAG, "removing the stationary region got no answer within $OS_GEOFENCE_TIMEOUT_MS ms")
747
+ } else {
748
+ Logger.d(TAG, "stationary region removed from ${target.kind.wire}")
749
+ }
750
+ } catch (e: CancellationException) {
751
+ throw e
752
+ } catch (e: Exception) {
753
+ Logger.w(TAG, "failed to remove the stationary region", e)
754
+ }
755
+ }
756
+
757
+ private fun startActivityUpdates(s: Session) {
758
+ if (s.mode != TrackingMode.LOCATION || s.activityBackend != null) return
759
+ val settings = config.activity
760
+ if (settings.disableMotionActivityUpdates) {
761
+ Logger.d(TAG, "activity updates disabled by config")
762
+ return
763
+ }
764
+ val backend = providers.activity()
765
+ if (!backend.isSupported) {
766
+ Logger.i(TAG, "activity recognition is not supported by the ${backend.kind.wire} backend")
767
+ return
768
+ }
769
+ if (!permissions.hasActivityRecognition()) {
770
+ Logger.w(TAG, "activity recognition permission is not granted; motion detection uses location only")
771
+ return
772
+ }
773
+ val started = try {
774
+ backend.start(settings.activityRecognitionInterval)
775
+ } catch (e: Exception) {
776
+ Logger.e(TAG, "activity updates failed to start", e)
777
+ false
778
+ }
779
+ if (started) s.activityBackend = backend else Logger.w(TAG, "activity updates could not be requested")
780
+ }
781
+
782
+ private fun stopActivityUpdates(s: Session) {
783
+ val backend = s.activityBackend ?: return
784
+ s.activityBackend = null
785
+ guarded("activity.stop") { backend.stop() }
786
+ }
787
+
788
+ private fun stopActivityBackend() {
789
+ guarded("activity.stop") {
790
+ val backend = providers.activity()
791
+ if (backend.isSupported) backend.stop()
792
+ }
793
+ }
794
+
795
+ /** Polygon geofences need continuous fixes while the device is inside one's enclosing circle. */
796
+ private fun watchGeofenceDemand(s: Session) {
797
+ s.geofenceWatchJob?.cancel()
798
+ s.geofenceWatchJob = scope.launch {
799
+ geofences.needsContinuousLocation.collect { needs ->
800
+ mutex.withLock {
801
+ if (session === s && s.needsContinuousLocation != needs) {
802
+ s.needsContinuousLocation = needs
803
+ Logger.d(TAG, "polygon geofences ${if (needs) "need" else "no longer need"} continuous location")
804
+ applyLocationRequest(s)
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ private fun scheduleElapsedStop(s: Session) {
812
+ val minutes = config.geolocation.stopAfterElapsedMinutes
813
+ if (minutes <= 0) {
814
+ timers.cancel(TimerKind.STOP_AFTER_ELAPSED)
815
+ return
816
+ }
817
+ val startedAt = runtime.trackingStartedAt ?: clock.now()
818
+ timers.schedule(TimerKind.STOP_AFTER_ELAPSED, startedAt + minutes * MINUTE_MS - clock.now()) {
819
+ if (session === s) {
820
+ Logger.i(TAG, "stopAfterElapsedMinutes ($minutes) reached")
821
+ guarded("stop after elapsed") { stopLocked(REASON_STOP_AFTER_ELAPSED) }
822
+ }
823
+ }
824
+ }
825
+
826
+ // ------------------------------------------------------------------ location pipeline
827
+
828
+ private fun dispatchLocations(s: Session, locations: List<TrackedLocation>) {
829
+ if (locations.isEmpty()) return
830
+ val batch = locations.toList()
831
+ scope.launch {
832
+ mutex.withLock {
833
+ for (raw in batch) {
834
+ if (session !== s) return@withLock
835
+ guarded("location fix") { handleFix(s, raw) }
836
+ }
837
+ // After the batch, so motion evidence in it wins over a stop timeout that expired while asleep.
838
+ fireDueTimers(s)
839
+ }
840
+ }
841
+ }
842
+
843
+ /**
844
+ * `delay()` does not advance while the CPU sleeps, so overdue timers are also checked on every fix, activity
845
+ * sample and heartbeat (whose alarm wakes the device every few minutes).
846
+ */
847
+ private suspend fun fireDueTimers(s: Session) {
848
+ if (session === s) guarded("timers") { timers.fireDue() }
849
+ }
850
+
851
+ private fun onEvent(s: Session, event: TrackingEvent) {
852
+ when (event) {
853
+ is TrackingEvent.Heartbeat -> scope.launch { mutex.withLock { fireDueTimers(s) } }
854
+ is TrackingEvent.ProviderChange -> scope.launch { mutex.withLock { onProviderChange(s, event.state) } }
855
+ // The user geofences may now need the OS slot of the stationary region, or leave it free again.
856
+ is TrackingEvent.GeofencesChange -> scope.launch {
857
+ mutex.withLock { if (session === s) applyMotionRequests(s, checkLimit = true) }
858
+ }
859
+ else -> Unit
860
+ }
861
+ }
862
+
863
+ /**
864
+ * GMS and HMS drop every registered geofence when location services are switched off, and geofences registered
865
+ * without background permission may not fire. Registering all stored geofences again when location comes back
866
+ * (or the permission level changes) keeps geofence audit records flowing. Re-adding the same ids is idempotent.
867
+ *
868
+ * The stationary region is registered again on every provider change while location is enabled (any change of
869
+ * the location settings or of the backend may have made the OS drop it). A registration that failed before (for
870
+ * example without background location permission) is tried again; on success the low-power fallback request
871
+ * becomes the passive one.
872
+ */
873
+ private suspend fun onProviderChange(s: Session, state: ProviderState) {
874
+ if (session !== s) return
875
+ val wasEnabled = s.providerEnabled
876
+ val oldPermission = s.providerPermission
877
+ s.providerEnabled = state.enabled
878
+ s.providerPermission = state.permission
879
+ if (!state.enabled) return
880
+ if (wasEnabled != true || oldPermission != state.permission) {
881
+ Logger.i(TAG, "location provider available again (${state.permission.wire}); re-registering geofences")
882
+ guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
883
+ }
884
+ if (isStationary(s)) applyMotionRequests(s, force = true)
885
+ }
886
+
887
+ private suspend fun handleFix(s: Session, raw: TrackedLocation) {
888
+ val fix = acceptedOrNull(raw, isMoving = s.motion.isMoving, what = "fix") ?: return
889
+ s.lastFix = fix
890
+ if (s.mode == TrackingMode.GEOFENCES) {
891
+ guarded("odometer") { odometer.onLocation(fix) }
892
+ notifyGeofences(fix)
893
+ persistLastLocation(s, fix)
894
+ return
895
+ }
896
+ val wasMoving = s.motion.isMoving
897
+ val anchorBefore = s.motion.anchor
898
+ val actions = s.motion.onLocation(fix)
899
+ if (!wasMoving && !s.motion.isMoving) {
900
+ // STATIONARY: no record, no odometer; `runtime.lastLocation` changes only with the anchor.
901
+ notifyGeofences(fix)
902
+ onStationaryFix(s, fix, anchorBefore)
903
+ return
904
+ }
905
+ guarded("odometer") { odometer.onLocation(fix) }
906
+ notifyGeofences(fix)
907
+ // Leaving STATIONARY records this fix as the `motionchange` (is_moving=true).
908
+ execute(s, actions)
909
+ if (session !== s || !wasMoving) return
910
+ if (ElasticDistance.shouldRecord(s.lastRecorded, fix, config.geolocation)) {
911
+ submit(RecordEvent.LOCATION, fix)
912
+ s.lastRecorded = fix
913
+ s.lastLocationPersistedAt = clock.elapsedRealtime()
914
+ } else {
915
+ persistLastLocation(s, fix)
916
+ }
917
+ }
918
+
919
+ private fun notifyGeofences(fix: TrackedLocation) {
920
+ guarded("geofences.onLocation") { geofences.onLocation(fix) }
921
+ }
922
+
923
+ /** The processed [raw] fix, or null (logged) if the processor rejects it. */
924
+ private fun acceptedOrNull(raw: TrackedLocation, isMoving: Boolean, what: String): TrackedLocation? =
925
+ when (val result = processor.process(raw, isMoving)) {
926
+ is FilterResult.Accepted -> result.location
927
+ is FilterResult.Rejected -> {
928
+ Logger.d(TAG, "$what rejected: ${result.reason}")
929
+ null
930
+ }
931
+ }
932
+
933
+ /**
934
+ * A STATIONARY fix that did not leave STATIONARY. It changes the anchor when:
935
+ * - there was none (nothing was known when STATIONARY began): the fix becomes the anchor;
936
+ * - the anchor is not confirmed (it was more than [ANCHOR_MAX_AGE_MS] old when it became the anchor) and the fix
937
+ * is current and accurate enough: the fix replaces it;
938
+ * - the motion state machine replaced the anchor with this more accurate fix.
939
+ * A new anchor becomes `runtime.lastLocation` (the location of the heartbeats) and the stationary region follows
940
+ * it (registered around a new confirmed anchor, moved only when the centre is more than [REGION_RECENTER_M]
941
+ * away). Otherwise `runtime.lastLocation` keeps the anchor fix with its acquisition time.
942
+ */
943
+ private suspend fun onStationaryFix(s: Session, fix: TrackedLocation, anchorBefore: TrackedLocation?) {
944
+ var anchor = s.motion.anchor ?: return
945
+ val replacesOldAnchor = anchor === anchorBefore && !isAnchorConfirmed(s, anchor) && isCurrent(fix) &&
946
+ s.motion.isAccurateEnough(fix)
947
+ if (replacesOldAnchor) {
948
+ Logger.i(TAG, "a current fix replaces the stationary anchor, which was ${ageMs(anchor) / 1000} s old")
949
+ s.motion.setAnchor(fix)
950
+ anchor = fix
951
+ }
952
+ if (anchor === anchorBefore) return
953
+ configStore.updateRuntime { it.copy(lastLocation = anchor) }
954
+ applyMotionRequests(s)
955
+ }
956
+
957
+ /**
958
+ * An OS transition of the stationary region. Only EXIT matters: while STATIONARY it switches to MOVING (the
959
+ * transition's fix, when the processor accepts it, is the `motionchange` location and goes to the odometer and
960
+ * the geofences like the fix that leaves the stationary radius). An EXIT whose fix is certainly inside the
961
+ * registered region belongs to an earlier region (delivered late) and is ignored. In a process where tracking
962
+ * is enabled but not running (the geofence PendingIntent started it) tracking is restored first. A region
963
+ * reported while tracking is off, or while the session is not STATIONARY, is removed from the OS.
964
+ */
965
+ private suspend fun handleStationaryRegionTransition(transition: OsGeofenceTransition) {
966
+ if (transition.action != GeofenceAction.EXIT) {
967
+ Logger.d(TAG, "stationary region ${transition.action} ignored")
968
+ return
969
+ }
970
+ if (session == null) {
971
+ if (!runtime.enabled) {
972
+ Logger.i(TAG, "stationary region EXIT while tracking is off; removing the region")
973
+ removeStationaryRegionFrom(null)
974
+ return
975
+ }
976
+ if (!restoreFromBackgroundTrigger("stationary region EXIT")) return
977
+ }
978
+ val s = session ?: return
979
+ // The OS has the region: this session removes it when it leaves STATIONARY or stops.
980
+ if (s.regionBackend == null) s.leftoverRegion = true
981
+ if (!isStationary(s)) {
982
+ Logger.d(TAG, "stationary region EXIT ignored: the session is not STATIONARY")
983
+ removeStationaryRegion(s)
984
+ return
985
+ }
986
+ val region = s.region
987
+ val reported = transition.location
988
+ if (region != null && reported != null &&
989
+ distanceMeters(region.latitude, region.longitude, reported.latitude, reported.longitude) +
990
+ reported.accuracyMeters <= region.radius
991
+ ) {
992
+ Logger.i(TAG, "stationary region EXIT ignored: its fix is inside the registered region (a late EXIT)")
993
+ return
994
+ }
995
+ Logger.i(TAG, "stationary region EXIT: leaving STATIONARY")
996
+ val fix = reported?.let { acceptedOrNull(it, isMoving = false, what = "stationary region EXIT fix") }
997
+ if (fix != null) {
998
+ s.lastFix = newest(s.lastFix, fix)
999
+ guarded("odometer") { odometer.onLocation(fix) }
1000
+ notifyGeofences(fix)
1001
+ }
1002
+ execute(s, s.motion.force(true, fix ?: bestKnownLocation(s)))
1003
+ fireDueTimers(s)
1004
+ }
1005
+
1006
+ /**
1007
+ * Keeps `runtime.lastLocation` (used by heartbeats) fresh for MOVING and GEOFENCES-mode fixes that are not
1008
+ * recorded, at most every 10 s. STATIONARY fixes never call it: the heartbeats keep the anchor fix.
1009
+ */
1010
+ private fun persistLastLocation(s: Session, fix: TrackedLocation) {
1011
+ val now = clock.elapsedRealtime()
1012
+ val last = s.lastLocationPersistedAt
1013
+ if (last != null && now - last < LAST_LOCATION_PERSIST_INTERVAL_MS) return
1014
+ s.lastLocationPersistedAt = now
1015
+ configStore.updateRuntime { it.copy(lastLocation = fix) }
1016
+ }
1017
+
1018
+ // ------------------------------------------------------------------ motion
1019
+
1020
+ private suspend fun handleActivity(samples: List<ActivitySample>) {
1021
+ if (session == null) {
1022
+ if (!runtime.enabled) {
1023
+ Logger.v(TAG, "activity ignored: tracking is not enabled")
1024
+ return
1025
+ }
1026
+ // The activity PendingIntent woke a process that was killed while tracking.
1027
+ if (!restoreFromBackgroundTrigger("activity update")) return
1028
+ }
1029
+ val s = session ?: return
1030
+ val best = samples.maxBy { it.confidence }
1031
+ val threshold = config.activity.minimumActivityRecognitionConfidence
1032
+ if (best.confidence >= threshold) {
1033
+ persistActivity(s, best)
1034
+ if (s.lastActivityType != best.type) {
1035
+ s.lastActivityType = best.type
1036
+ Logger.d(TAG, "activity: ${best.type.wire} (${best.confidence})")
1037
+ events.emit(TrackingEvent.ActivityChange(best))
1038
+ }
1039
+ if (s.mode == TrackingMode.LOCATION) execute(s, s.motion.onActivity(best))
1040
+ } else {
1041
+ Logger.v(TAG, "activity ${best.type.wire} (${best.confidence}) below confidence $threshold")
1042
+ }
1043
+ fireDueTimers(s)
1044
+ }
1045
+
1046
+ /** `runtime.activity`: a new type is written at once, a confidence-only change at most once a minute. */
1047
+ private fun persistActivity(s: Session, sample: ActivitySample) {
1048
+ val current = runtime.activity
1049
+ if (current == sample) return
1050
+ val now = clock.elapsedRealtime()
1051
+ val last = s.lastActivityPersistedAt
1052
+ if (current.type == sample.type && last != null && now - last < ACTIVITY_PERSIST_INTERVAL_MS) return
1053
+ s.lastActivityPersistedAt = now
1054
+ configStore.updateRuntime { it.copy(activity = sample) }
1055
+ }
1056
+
1057
+ private suspend fun execute(s: Session, actions: List<MotionAction>) {
1058
+ for (action in actions) {
1059
+ if (session !== s) return
1060
+ when (action) {
1061
+ is MotionAction.StartStopTimer ->
1062
+ timers.schedule(TimerKind.STOP_TIMEOUT, action.delayMs) { onStopTimeout(s) }
1063
+ MotionAction.CancelStopTimer -> timers.cancel(TimerKind.STOP_TIMEOUT)
1064
+ is MotionAction.StartMotionTrigger ->
1065
+ timers.schedule(TimerKind.MOTION_TRIGGER, action.delayMs) { onMotionTrigger(s) }
1066
+ MotionAction.CancelMotionTrigger -> timers.cancel(TimerKind.MOTION_TRIGGER)
1067
+ is MotionAction.EnterMoving -> {
1068
+ Logger.i(TAG, "motion: STATIONARY -> MOVING")
1069
+ val location = action.location ?: bestKnownLocation(s)
1070
+ // The configured request (GPS on) at once; the stationary region is removed after the record,
1071
+ // so a slow OS call delays neither.
1072
+ applyLocationRequest(s)
1073
+ recordMotionChange(s, isMoving = true, location)
1074
+ applyMotionRequests(s)
1075
+ }
1076
+ is MotionAction.EnterStationary -> {
1077
+ Logger.i(TAG, "motion: MOVING -> STATIONARY${if (action.automatic) " (stop timeout)" else ""}")
1078
+ recordMotionChange(s, isMoving = false, action.location)
1079
+ if (action.automatic && config.geolocation.stopOnStationary) {
1080
+ stopLocked(REASON_STOP_ON_STATIONARY)
1081
+ } else {
1082
+ // The stationary region around the anchor and passive updates (GPS off).
1083
+ applyMotionRequests(s)
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ private suspend fun onStopTimeout(s: Session) {
1091
+ if (session !== s || !s.motion.isMoving) return
1092
+ guarded("stop timeout") { execute(s, s.motion.onStopTimeout(bestKnownLocation(s))) }
1093
+ }
1094
+
1095
+ private suspend fun onMotionTrigger(s: Session) {
1096
+ if (session !== s) return
1097
+ guarded("motion trigger") { execute(s, s.motion.onMotionTriggerElapsed()) }
1098
+ }
1099
+
1100
+ private suspend fun recordMotionChange(s: Session, isMoving: Boolean, location: TrackedLocation?) {
1101
+ configStore.updateRuntime { it.copy(isMoving = isMoving) }
1102
+ submit(RecordEvent.MOTIONCHANGE, location, isMoving = isMoving)
1103
+ s.motionRecorded = true
1104
+ if (location != null) {
1105
+ s.lastRecorded = location
1106
+ s.lastLocationPersistedAt = clock.elapsedRealtime()
1107
+ }
1108
+ }
1109
+
1110
+ /** After start: a fresh fix (or the last known one) is recorded as the initial `motionchange` (is_moving=false). */
1111
+ private fun launchInitialFix(s: Session) {
1112
+ s.initialFixJob?.cancel()
1113
+ val geolocation = config.geolocation
1114
+ s.initialFixJob = scope.launch {
1115
+ val fresh = currentLocation(geolocation)
1116
+ mutex.withLock {
1117
+ // Skipped if tracking stopped, the mode changed, or a transition was already recorded.
1118
+ if (session !== s || s.mode != TrackingMode.LOCATION || s.motionRecorded) return@withLock
1119
+ guarded("initial motionchange") { recordInitialMotionChange(s, fresh) }
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ private suspend fun recordInitialMotionChange(s: Session, fresh: TrackedLocation?) {
1125
+ val accepted = fresh?.let { acceptedOrNull(it, isMoving = false, what = "initial fix") }
1126
+ if (accepted != null) {
1127
+ s.lastFix = newest(s.lastFix, accepted)
1128
+ notifyGeofences(accepted)
1129
+ // The fresh fix is the anchor, also when a passive fix arrived first and became the anchor.
1130
+ s.motion.setAnchor(accepted)
1131
+ }
1132
+ val location = accepted ?: bestKnownLocation(s)
1133
+ s.motion.offerAnchor(location)
1134
+ recordMotionChange(s, isMoving = false, location)
1135
+ if (s.motion.anchor == null) {
1136
+ Logger.i(TAG, "no location is known: low-power requests until a first fix becomes the stationary anchor")
1137
+ }
1138
+ // Entering STATIONARY after start / restore: the stationary region around the anchor, passive updates.
1139
+ applyMotionRequests(s)
1140
+ }
1141
+
1142
+ private suspend fun currentLocation(geolocation: GeolocationConfig): TrackedLocation? = try {
1143
+ withTimeoutOrNull(geolocation.locationTimeout.coerceAtLeast(0) + CURRENT_LOCATION_GRACE_MS) {
1144
+ providers.location().getCurrentLocation(geolocation.desiredAccuracy, geolocation.locationTimeout)
1145
+ }
1146
+ } catch (e: CancellationException) {
1147
+ throw e
1148
+ } catch (e: Exception) {
1149
+ Logger.w(TAG, "current location failed", e)
1150
+ null
1151
+ }
1152
+
1153
+ /**
1154
+ * The newest of the last accepted fix, `runtime.lastLocation` and the backend's last location. The backend's
1155
+ * location (not vetted by the processor, possibly a coarse network fix) only counts if its accuracy is within
1156
+ * `filter.trackingAccuracyThreshold`, unless nothing else is known.
1157
+ */
1158
+ private suspend fun bestKnownLocation(s: Session?): TrackedLocation? {
1159
+ val fromBackend = try {
1160
+ withTimeoutOrNull(LAST_LOCATION_TIMEOUT_MS) { providers.location().getLastLocation() }
1161
+ } catch (e: CancellationException) {
1162
+ throw e
1163
+ } catch (e: Exception) {
1164
+ Logger.w(TAG, "last location failed", e)
1165
+ null
1166
+ }
1167
+ val threshold = config.geolocation.filter.trackingAccuracyThreshold
1168
+ val vetted = fromBackend?.takeIf { threshold <= 0.0 || it.accuracyMeters <= threshold }
1169
+ return listOfNotNull(s?.lastFix, runtime.lastLocation, vetted).maxByOrNull { it.time } ?: fromBackend
1170
+ }
1171
+
1172
+ private suspend fun submit(
1173
+ event: RecordEvent,
1174
+ location: TrackedLocation?,
1175
+ reason: String? = null,
1176
+ isMoving: Boolean? = null,
1177
+ ): Record? = try {
1178
+ var record = recordFactory.create(event, location, reason = reason)
1179
+ if (isMoving != null && record.isMoving != isMoving) record = record.copy(isMoving = isMoving)
1180
+ recordSink.submit(record)
1181
+ } catch (e: CancellationException) {
1182
+ throw e
1183
+ } catch (e: Exception) {
1184
+ Logger.e(TAG, "failed to record ${event.wire}", e)
1185
+ null
1186
+ }
1187
+
1188
+ private suspend fun <T> serialized(block: suspend () -> T): T =
1189
+ withContext(engineContext) { mutex.withLock { block() } }
1190
+
1191
+ /** Runs [block], logging (not propagating) any failure except cancellation. */
1192
+ private inline fun guarded(what: String, block: () -> Unit) {
1193
+ try {
1194
+ block()
1195
+ } catch (e: CancellationException) {
1196
+ throw e
1197
+ } catch (e: Exception) {
1198
+ Logger.e(TAG, "$what failed", e)
1199
+ }
1200
+ }
1201
+
1202
+ /** State of one tracking session in this process. Guarded by [mutex]. */
1203
+ private inner class Session(val id: Long, var mode: TrackingMode, settings: MotionSettings) {
1204
+ val motion = MotionStateMachine(settings)
1205
+ val listener = LocationListener { locations -> dispatchLocations(this@Session, locations) }
1206
+ var locationBackend: LocationBackend? = null
1207
+ var appliedSpec: LocationRequestSpec? = null
1208
+ var activityBackend: ActivityBackend? = null
1209
+ var needsContinuousLocation = false
1210
+
1211
+ /** The stationary region registered with the OS (null: none; STATIONARY then uses the low-power request). */
1212
+ var region: OsGeofence? = null
1213
+
1214
+ /** The backend the stationary region was added to (also after a failed or timed-out add), for its removal. */
1215
+ var regionBackend: GeofenceBackend? = null
1216
+
1217
+ /** The last registration of the stationary region failed; not retried until this is cleared. */
1218
+ var regionFailed = false
1219
+
1220
+ /** An earlier process may have left the stationary region with the OS; removed with this session's. */
1221
+ var leftoverRegion = false
1222
+
1223
+ /** The motion anchor that was current when it became the anchor (see `isAnchorConfirmed`). */
1224
+ var confirmedAnchor: TrackedLocation? = null
1225
+
1226
+ /** Last fix accepted by the processor. */
1227
+ var lastFix: TrackedLocation? = null
1228
+
1229
+ /** Location of the last `location` / `motionchange` record (reference of the elastic distance filter). */
1230
+ var lastRecorded: TrackedLocation? = null
1231
+ var lastLocationPersistedAt: Long? = null
1232
+ var lastActivityType: ActivityType? = null
1233
+ var lastActivityPersistedAt: Long? = null
1234
+ var motionRecorded = false
1235
+ var initialFixJob: Job? = null
1236
+ var geofenceWatchJob: Job? = null
1237
+ var eventSubscription: Subscription? = null
1238
+
1239
+ /** Last provider state seen through `ProviderChange` events (null until the first event of this session). */
1240
+ var providerEnabled: Boolean? = null
1241
+ var providerPermission: PermissionLevel? = null
1242
+ }
1243
+
1244
+ private companion object {
1245
+ const val TAG = "LT.Engine"
1246
+
1247
+ const val REASON_START = "start"
1248
+ const val REASON_START_GEOFENCES = "start_geofences"
1249
+ const val REASON_RESTORE = "restore"
1250
+ const val REASON_STOP = "stop"
1251
+ const val REASON_STOP_ON_STATIONARY = "stop_on_stationary"
1252
+ const val REASON_STOP_AFTER_ELAPSED = "stop_after_elapsed"
1253
+ const val REASON_TERMINATE = "terminate"
1254
+ const val REASON_PERMISSION_DENIED = "permission_denied"
1255
+ const val REASON_SERVICE_START_FAILED = "service_start_failed"
1256
+ const val REASON_RESUME_NOTIFICATION = "resume_notification"
1257
+
1258
+ /** `device.checkProviderState` reason after the backend changed. */
1259
+ const val PROVIDER_CHECK_REASON = "reselect"
1260
+
1261
+ const val MINUTE_MS = 60_000L
1262
+ const val LAST_LOCATION_TIMEOUT_MS = 3_000L
1263
+ const val CURRENT_LOCATION_GRACE_MS = 5_000L
1264
+ const val LAST_LOCATION_PERSIST_INTERVAL_MS = 10_000L
1265
+ const val ACTIVITY_PERSIST_INTERVAL_MS = 60_000L
1266
+
1267
+ /** Longest wait for the OS to add or remove the stationary region (the engine's mutex is held meanwhile). */
1268
+ const val OS_GEOFENCE_TIMEOUT_MS = 10_000L
1269
+
1270
+ /**
1271
+ * An anchor older than this when it becomes the anchor is not confirmed: no stationary region is registered
1272
+ * around it until a current fix replaces it (the low-power request provides one).
1273
+ */
1274
+ const val ANCHOR_MAX_AGE_MS = 10 * MINUTE_MS
1275
+
1276
+ /** The stationary region is moved when the anchor is farther than this from its centre. */
1277
+ const val REGION_RECENTER_M = 50.0
1278
+
1279
+ fun newest(a: TrackedLocation?, b: TrackedLocation): TrackedLocation = if (a == null || b.time >= a.time) b else a
1280
+ }
1281
+ }