@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,899 @@
1
+ package com.brickssoft.locationtracking.geofence
2
+
3
+ import com.brickssoft.locationtracking.config.ConfigStore
4
+ import com.brickssoft.locationtracking.config.TrackingMode
5
+ import com.brickssoft.locationtracking.core.Clock
6
+ import com.brickssoft.locationtracking.core.Constants
7
+ import com.brickssoft.locationtracking.core.ErrorCode
8
+ import com.brickssoft.locationtracking.core.EventBus
9
+ import com.brickssoft.locationtracking.core.Logger
10
+ import com.brickssoft.locationtracking.core.TrackingEvent
11
+ import com.brickssoft.locationtracking.core.TrackingException
12
+ import com.brickssoft.locationtracking.data.GeofenceRuntime
13
+ import com.brickssoft.locationtracking.data.GeofenceStore
14
+ import com.brickssoft.locationtracking.model.GeofenceAction
15
+ import com.brickssoft.locationtracking.model.GeofenceHit
16
+ import com.brickssoft.locationtracking.model.GeofenceSpec
17
+ import com.brickssoft.locationtracking.model.RecordEvent
18
+ import com.brickssoft.locationtracking.model.TrackedLocation
19
+ import com.brickssoft.locationtracking.processing.RecordFactory
20
+ import com.brickssoft.locationtracking.provider.GeofenceBackend
21
+ import com.brickssoft.locationtracking.provider.OsGeofence
22
+ import com.brickssoft.locationtracking.provider.OsGeofenceTransition
23
+ import com.brickssoft.locationtracking.provider.ProviderFactory
24
+ import com.brickssoft.locationtracking.provider.StationaryRegionSink
25
+ import com.brickssoft.locationtracking.record.RecordSink
26
+ import kotlinx.coroutines.CoroutineScope
27
+ import kotlinx.coroutines.Job
28
+ import kotlinx.coroutines.delay
29
+ import kotlinx.coroutines.flow.MutableStateFlow
30
+ import kotlinx.coroutines.flow.StateFlow
31
+ import kotlinx.coroutines.flow.asStateFlow
32
+ import kotlinx.coroutines.launch
33
+ import kotlinx.coroutines.sync.Mutex
34
+ import kotlinx.coroutines.sync.withLock
35
+ import kotlin.coroutines.cancellation.CancellationException
36
+
37
+ /**
38
+ * Default [GeofenceManager]: validation, persistence, OS registration, polygon hit-testing and dwell synthesis.
39
+ *
40
+ * - **Circles** are registered as they are and their transitions come from the OS. When the backend has no dwell
41
+ * support, DWELL is synthesized with [DwellTracker] (continuously inside for `loiteringDelay` after ENTER).
42
+ * - **Polygons** are registered as their padded enclosing circle ([PolygonMath.enclosingCircle]) with ENTER, EXIT
43
+ * and an initial ENTER trigger. The circle state only drives [needsContinuousLocation]. Polygon ENTER and EXIT
44
+ * come from [onLocation] by ray casting, with hysteresis: a fix changes the state when its accuracy circle lies
45
+ * entirely on one side of the boundary, or when two consecutive fixes agree. Leaving the circle also means
46
+ * leaving the polygon. Polygon DWELL is always synthesized.
47
+ * - `initialTriggerEntry=false` silences the first polygon classification when it reflects the state at
48
+ * registration: when it happens within [INITIAL_TRIGGER_WINDOW_MS] of registering, or follows a circle ENTER
49
+ * received within that window (the OS's initial trigger). A circle ENTER after the window, or a fresh fix
50
+ * outside the enclosing circle at registration, means the polygon was outside. Without a fresh fix, a real
51
+ * entry within the window cannot be told apart from the initial state and is silent too.
52
+ * - Every notified transition becomes a `geofence` record (submitted to [RecordSink], so it is persisted and
53
+ * uploaded) plus a [TrackingEvent.Geofence].
54
+ *
55
+ * Per-geofence runtime ([GeofenceRuntime]) is persisted, restored when first loaded while tracking is enabled and
56
+ * reset by [onTrackingStopped]. `insideCircle` is the state of the (enclosing) circle. `insidePolygon` is the
57
+ * polygon state. `enteredAt` is the wall time at which the current inside state began (circles) or at which the
58
+ * current polygon state was established (polygons: null = not classified yet, [ENTERED_BEFORE_MONITORING] = a
59
+ * silent initial inside). A restored state may be stale, so a restored circle ENTER is not treated as a duplicate,
60
+ * and a fix that is certainly outside a restored inside circle reports the EXIT missed while the process was
61
+ * gone. A synthesized dwell is re-armed after a restart only if its deadline has not passed.
62
+ *
63
+ * State changes are serialized with a [Mutex]; [onLocation] only launches work on [scope].
64
+ */
65
+ class DefaultGeofenceManager(
66
+ private val geofenceStore: GeofenceStore,
67
+ private val providers: ProviderFactory,
68
+ private val configStore: ConfigStore,
69
+ private val recordFactory: RecordFactory,
70
+ private val recordSink: RecordSink,
71
+ private val events: EventBus,
72
+ private val clock: Clock,
73
+ private val scope: CoroutineScope,
74
+ // Unit 2 routes STATIONARY_REGION_ID transitions here (never stored, recorded or emitted).
75
+ @Suppress("unused")
76
+ private val stationarySink: Lazy<StationaryRegionSink> = lazyOf(StationaryRegionSink.NONE),
77
+ ) : GeofenceManager {
78
+ private class Entry(val spec: GeofenceSpec, var runtime: GeofenceRuntime) {
79
+ /** Circle: the OS reported (or a fix confirmed) the state in this process; restored state may be stale. */
80
+ var observed = false
81
+
82
+ /** Polygon: an unclassified state is the initial state until this time (the OS initial-trigger window). */
83
+ var initialUntil = Long.MIN_VALUE
84
+
85
+ /** Polygon: the OS initial circle ENTER arrived, so the first classification is the initial state. */
86
+ var initialLatched = false
87
+
88
+ /** Polygon: the last fix classified, so the same fix from two sources counts once. */
89
+ var lastFix: TrackedLocation? = null
90
+
91
+ /** Polygon hysteresis: the unconfirmed classification and how many consecutive fixes agreed with it. */
92
+ var candidate: Boolean? = null
93
+ var candidateCount = 0
94
+
95
+ fun resetCandidate() {
96
+ candidate = null
97
+ candidateCount = 0
98
+ }
99
+ }
100
+
101
+ private val mutex = Mutex()
102
+
103
+ // Guarded by [mutex].
104
+ private val entries = LinkedHashMap<String, Entry>()
105
+ private var registeredBackend: GeofenceBackend? = null
106
+
107
+ /** The last fix of the location stream (for ordering). */
108
+ private var lastStreamFix: TrackedLocation? = null
109
+
110
+ /** A later registration of every geofence, after the backend answered UNAVAILABLE ([scheduleRetryLocked]). */
111
+ private var retryJob: Job? = null
112
+ private var retryAttempt = 0
113
+
114
+ /** True after [onTrackingStarted], false after [onTrackingStopped], null in a process that saw neither. */
115
+ @Volatile
116
+ private var session: Boolean? = null
117
+
118
+ @Volatile
119
+ private var loaded = false
120
+
121
+ /** True while fixes matter: active polygons, or restored inside circles to reconcile. */
122
+ @Volatile
123
+ private var wantsFixes = false
124
+
125
+ @Volatile
126
+ private var lastFix: TrackedLocation? = null
127
+
128
+ private val dwell = DwellTracker(scope, clock) { id, token -> mutex.withLock { fireDwellLocked(id, token) } }
129
+
130
+ private val needs = MutableStateFlow(false)
131
+ override val needsContinuousLocation: StateFlow<Boolean> = needs.asStateFlow()
132
+
133
+ override suspend fun add(geofences: List<GeofenceSpec>) {
134
+ if (geofences.isEmpty()) return
135
+ val incoming = LinkedHashMap<String, GeofenceSpec>()
136
+ for (spec in geofences) validated(spec).let { incoming[it.identifier] = it }
137
+ val specs = incoming.values.toList()
138
+
139
+ mutex.withLock {
140
+ ensureLoadedLocked()
141
+ val newIds = specs.map { it.identifier }.filter { it !in entries }
142
+ val total = entries.size + newIds.size
143
+ if (total > Constants.MAX_GEOFENCES) {
144
+ throw TrackingException(
145
+ ErrorCode.TOO_MANY_GEOFENCES,
146
+ "at most ${Constants.MAX_GEOFENCES} geofences are supported (this would make $total)",
147
+ )
148
+ }
149
+ val replaced = specs.mapNotNull { entries[it.identifier]?.spec }
150
+ val backend = if (configStore.runtime.value.enabled) backendLocked() else null
151
+ if (backend != null) {
152
+ try {
153
+ val deactivated = replaced.filter { isActive(it) }.map { it.identifier } -
154
+ specs.filter { isActive(it) }.map { it.identifier }.toSet()
155
+ if (deactivated.isNotEmpty()) backend.remove(deactivated)
156
+ registerLocked(backend, specs, throwOnError = true)
157
+ } catch (e: CancellationException) {
158
+ throw e
159
+ } catch (e: Exception) {
160
+ rollbackRegistrationLocked(backend, newIds, replaced)
161
+ throw e.toTrackingException(ErrorCode.INTERNAL, "failed to register geofences")
162
+ }
163
+ }
164
+ try {
165
+ geofenceStore.upsert(specs)
166
+ } catch (e: CancellationException) {
167
+ throw e
168
+ } catch (e: Exception) {
169
+ if (backend != null) rollbackRegistrationLocked(backend, newIds, replaced)
170
+ throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to store geofences")
171
+ }
172
+ val now = clock.now()
173
+ for (spec in specs) {
174
+ val previous = entries[spec.identifier]
175
+ dwell.cancel(spec.identifier)
176
+ val entry = Entry(spec, UNKNOWN)
177
+ entries[spec.identifier] = entry
178
+ if (previous != null && previous.runtime != UNKNOWN) persistRuntime(spec.identifier, UNKNOWN)
179
+ if (backend != null) openInitialWindowLocked(entry, now)
180
+ }
181
+ refreshDerivedLocked()
182
+ Logger.i(TAG, "added ${specs.size} geofence(s): ${specs.joinToString { it.identifier }}")
183
+ events.emit(TrackingEvent.GeofencesChange(on = specs, off = emptyList()))
184
+ }
185
+ }
186
+
187
+ override suspend fun remove(ids: List<String>) {
188
+ if (ids.isEmpty()) return
189
+ mutex.withLock {
190
+ ensureLoadedLocked()
191
+ val requested = ids.distinct()
192
+ val existing = requested.filter { it in entries }
193
+ if (existing.size < requested.size) {
194
+ Logger.d(TAG, "remove: unknown geofence(s) ${(requested - existing.toSet()).joinToString()}")
195
+ }
196
+ if (existing.isEmpty()) return
197
+ try {
198
+ geofenceStore.remove(existing)
199
+ } catch (e: CancellationException) {
200
+ throw e
201
+ } catch (e: Exception) {
202
+ throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to remove geofences")
203
+ }
204
+ if (configStore.runtime.value.enabled) {
205
+ removeFromOsLocked(backendLocked(), existing.filter { isActive(entries.getValue(it).spec) })
206
+ }
207
+ for (id in existing) {
208
+ dwell.cancel(id)
209
+ entries.remove(id)
210
+ }
211
+ refreshDerivedLocked()
212
+ Logger.i(TAG, "removed ${existing.size} geofence(s): ${existing.joinToString()}")
213
+ events.emit(TrackingEvent.GeofencesChange(on = emptyList(), off = existing))
214
+ }
215
+ }
216
+
217
+ override suspend fun removeAll() {
218
+ mutex.withLock {
219
+ ensureLoadedLocked()
220
+ val ids = entries.keys.toList()
221
+ val registered = registrableIdsLocked()
222
+ try {
223
+ geofenceStore.removeAll()
224
+ } catch (e: CancellationException) {
225
+ throw e
226
+ } catch (e: Exception) {
227
+ throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to remove geofences")
228
+ }
229
+ if (configStore.runtime.value.enabled) {
230
+ // The stored ids only: `backend.removeAll()` would also drop the engine's stationary region, which
231
+ // shares the backend's PendingIntent (round 2, §1 and §3).
232
+ val current = providers.geofence()
233
+ for (backend in listOfNotNull(registeredBackend, current).distinctBy { it.kind }) {
234
+ removeFromOsLocked(backend, registered)
235
+ }
236
+ registeredBackend = current
237
+ }
238
+ dwell.cancelAll()
239
+ entries.clear()
240
+ refreshDerivedLocked()
241
+ if (ids.isEmpty()) return
242
+ Logger.i(TAG, "removed all ${ids.size} geofence(s)")
243
+ events.emit(TrackingEvent.GeofencesChange(on = emptyList(), off = ids))
244
+ }
245
+ }
246
+
247
+ override suspend fun list(): List<GeofenceSpec> = geofenceStore.all()
248
+
249
+ override suspend fun get(id: String): GeofenceSpec? = geofenceStore.get(id)
250
+
251
+ override suspend fun onTrackingStarted(mode: TrackingMode) {
252
+ mutex.withLock {
253
+ session = true
254
+ try {
255
+ ensureLoadedLocked()
256
+ } catch (e: CancellationException) {
257
+ throw e
258
+ } catch (e: Exception) {
259
+ Logger.e(TAG, "cannot load geofences; none registered", e)
260
+ return
261
+ }
262
+ val current = providers.geofence()
263
+ registeredBackend?.takeIf { it.kind != current.kind }?.let {
264
+ removeFromOsLocked(it, registrableIdsLocked())
265
+ }
266
+ registeredBackend = current
267
+ cancelRetryLocked()
268
+ if (registerAllLocked(current)) scheduleRetryLocked()
269
+ refreshDwellTimersLocked()
270
+ refreshDerivedLocked()
271
+ Logger.i(TAG, "tracking started (${mode.wire}): ${entries.size} geofence(s), backend ${current.kind.wire}")
272
+ }
273
+ }
274
+
275
+ override suspend fun onTrackingStopped() {
276
+ mutex.withLock {
277
+ session = false
278
+ cancelRetryLocked()
279
+ dwell.cancelAll()
280
+ lastStreamFix = null
281
+ try {
282
+ ensureLoadedLocked()
283
+ } catch (e: CancellationException) {
284
+ throw e
285
+ } catch (e: Exception) {
286
+ Logger.e(TAG, "cannot load geofences to unregister them", e)
287
+ refreshDerivedLocked()
288
+ return
289
+ }
290
+ removeFromOsLocked(registeredBackend ?: providers.geofence(), registrableIdsLocked())
291
+ registeredBackend = null
292
+ for (entry in entries.values) {
293
+ entry.observed = false
294
+ entry.initialLatched = false
295
+ entry.lastFix = null
296
+ entry.resetCandidate()
297
+ if (entry.runtime != UNKNOWN) persistRuntime(entry, UNKNOWN)
298
+ }
299
+ refreshDerivedLocked()
300
+ Logger.i(TAG, "tracking stopped: ${entries.size} geofence(s) unregistered")
301
+ }
302
+ }
303
+
304
+ override fun onLocation(location: TrackedLocation) {
305
+ val previous = lastFix
306
+ if (previous == null || location.time >= previous.time) lastFix = location
307
+ if (session == false || !configStore.runtime.value.enabled) return
308
+ if (loaded && !wantsFixes && !dwell.hasArmed()) return
309
+ scope.launch {
310
+ mutex.withLock {
311
+ try {
312
+ handleLocationLocked(location)
313
+ } catch (e: CancellationException) {
314
+ throw e
315
+ } catch (e: Exception) {
316
+ Logger.e(TAG, "failed to process location for geofences", e)
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ override suspend fun onGeofenceTransitions(transitions: List<OsGeofenceTransition>) {
323
+ if (transitions.isEmpty()) return
324
+ // The engine's stationary region (round 2, §3) is routed before any other check: its transitions are never
325
+ // stored, recorded, emitted or counted. They are handed to the engine after the user geofences of the same
326
+ // batch, so the engine's work (a restore, OS calls) does not delay the geofence audit records, and outside
327
+ // [mutex], because the engine holds its own lock while it calls onTrackingStarted / onTrackingStopped.
328
+ val (stationary, others) = transitions.partition { it.id == Constants.STATIONARY_REGION_ID }
329
+ if (others.isNotEmpty()) {
330
+ mutex.withLock {
331
+ if (session == false || !configStore.runtime.value.enabled) {
332
+ Logger.w(TAG, "ignoring ${others.size} geofence transition(s): tracking is stopped")
333
+ return@withLock
334
+ }
335
+ try {
336
+ ensureLoadedLocked()
337
+ } catch (e: CancellationException) {
338
+ throw e
339
+ } catch (e: Exception) {
340
+ Logger.e(TAG, "cannot load geofences; dropping ${others.size} transition(s)", e)
341
+ return@withLock
342
+ }
343
+ for (transition in others) {
344
+ val entry = entries[transition.id]
345
+ if (entry == null) {
346
+ Logger.w(TAG, "ignoring ${transition.action} for unknown geofence '${transition.id}'")
347
+ continue
348
+ }
349
+ Logger.d(TAG, "OS transition ${transition.action} '${transition.id}'")
350
+ if (entry.spec.isPolygon) {
351
+ onPolygonCircleTransitionLocked(entry, transition)
352
+ } else {
353
+ onCircleTransitionLocked(entry, transition)
354
+ }
355
+ }
356
+ refreshDerivedLocked()
357
+ }
358
+ }
359
+ for (transition in stationary) {
360
+ try {
361
+ stationarySink.value.onStationaryRegionTransition(transition)
362
+ } catch (e: CancellationException) {
363
+ throw e
364
+ } catch (e: Exception) {
365
+ Logger.e(TAG, "failed to deliver ${transition.action} of the stationary region", e)
366
+ }
367
+ }
368
+ }
369
+
370
+ // ---- circles
371
+
372
+ private suspend fun onCircleTransitionLocked(entry: Entry, transition: OsGeofenceTransition) {
373
+ val spec = entry.spec
374
+ val synthesizeDwell = synthesizesDwell(spec, activeBackend())
375
+ val wasObserved = entry.observed
376
+ entry.observed = true
377
+ when (transition.action) {
378
+ GeofenceAction.ENTER -> {
379
+ // With ENTER and EXIT both registered, a second ENTER while inside (the OS initial trigger after
380
+ // re-registering) is a duplicate. Restored state may be stale, so it only counts once observed.
381
+ val tracksState = (spec.notifyOnEntry || synthesizeDwell) && (spec.notifyOnExit || synthesizeDwell)
382
+ if (tracksState && wasObserved && entry.runtime.insideCircle) {
383
+ Logger.d(TAG, "duplicate ENTER '${spec.identifier}' ignored")
384
+ return
385
+ }
386
+ val now = clock.now()
387
+ persistRuntime(entry, GeofenceRuntime(insideCircle = true, insidePolygon = false, enteredAt = now))
388
+ if (spec.notifyOnEntry) notifyLocked(spec, GeofenceAction.ENTER, transition.location)
389
+ if (synthesizeDwell) dwell.arm(spec.identifier, now + spec.loiteringDelay, token = now)
390
+ }
391
+ GeofenceAction.EXIT -> {
392
+ dwell.cancel(spec.identifier)
393
+ persistRuntime(entry, UNKNOWN)
394
+ if (spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, transition.location)
395
+ }
396
+ GeofenceAction.DWELL -> {
397
+ dwell.cancel(spec.identifier)
398
+ if (!entry.runtime.insideCircle) {
399
+ val now = clock.now()
400
+ persistRuntime(entry, GeofenceRuntime(insideCircle = true, insidePolygon = false, enteredAt = now))
401
+ }
402
+ if (spec.notifyOnDwell) notifyLocked(spec, GeofenceAction.DWELL, transition.location)
403
+ }
404
+ }
405
+ }
406
+
407
+ /** A restored inside circle is checked against fixes: certainly outside means the OS EXIT was lost. */
408
+ private suspend fun reconcileCircleLocked(entry: Entry, location: TrackedLocation) {
409
+ val spec = entry.spec
410
+ val accuracy = accuracyOf(location)
411
+ val fromCenter = distanceFromCenter(spec, location)
412
+ when {
413
+ fromCenter - accuracy > spec.radius -> {
414
+ Logger.i(TAG, "'${spec.identifier}' was left while tracking was not running")
415
+ entry.observed = true
416
+ dwell.cancel(spec.identifier)
417
+ persistRuntime(entry, UNKNOWN)
418
+ if (spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, location)
419
+ }
420
+ fromCenter + accuracy <= spec.radius -> entry.observed = true
421
+ }
422
+ }
423
+
424
+ // ---- polygons
425
+
426
+ private suspend fun onPolygonCircleTransitionLocked(entry: Entry, transition: OsGeofenceTransition) {
427
+ when (transition.action) {
428
+ GeofenceAction.ENTER -> {
429
+ var runtime = entry.runtime.copy(insideCircle = true)
430
+ if (runtime.enteredAt == null) {
431
+ if (clock.now() <= entry.initialUntil) {
432
+ entry.initialLatched = true // the OS initial trigger: we were in the circle at registration
433
+ } else {
434
+ runtime = runtime.copy(insidePolygon = false, enteredAt = clock.now()) // a real entry
435
+ }
436
+ }
437
+ if (runtime != entry.runtime) persistRuntime(entry, runtime)
438
+ val location = transition.location ?: return
439
+ val previous = lastStreamFix
440
+ if (previous == null || location.time >= previous.time) evaluatePolygonLocked(entry, location)
441
+ }
442
+ GeofenceAction.EXIT -> {
443
+ // The enclosing circle contains the polygon, so leaving it means leaving the polygon.
444
+ val wasInside = entry.runtime.insidePolygon
445
+ val stateSince = entry.runtime.enteredAt.takeUnless { wasInside } ?: clock.now()
446
+ dwell.cancel(entry.spec.identifier)
447
+ entry.resetCandidate()
448
+ entry.initialLatched = false
449
+ persistRuntime(entry, GeofenceRuntime(insideCircle = false, insidePolygon = false, stateSince))
450
+ if (wasInside && entry.spec.notifyOnExit) {
451
+ notifyLocked(entry.spec, GeofenceAction.EXIT, transition.location)
452
+ }
453
+ }
454
+ GeofenceAction.DWELL -> Logger.d(TAG, "ignoring OS DWELL for polygon '${entry.spec.identifier}'")
455
+ }
456
+ }
457
+
458
+ private suspend fun handleLocationLocked(location: TrackedLocation) {
459
+ if (session == false || !configStore.runtime.value.enabled) return
460
+ ensureLoadedLocked()
461
+ val previous = lastStreamFix
462
+ if (previous != null) {
463
+ val olderBy = previous.time - location.time
464
+ // Skip a fix older than the previous one (unless the gap is so large that the earlier timestamp must have
465
+ // been bogus) and the same fix delivered twice.
466
+ if (olderBy in 1..MAX_REORDER_MS || (olderBy == 0L && sameFix(previous, location))) return
467
+ }
468
+ lastStreamFix = location
469
+ for (entry in entries.values) {
470
+ when {
471
+ entry.spec.isPolygon -> evaluatePolygonLocked(entry, location)
472
+ !entry.observed && entry.runtime.insideCircle && isActive(entry.spec) ->
473
+ reconcileCircleLocked(entry, location)
474
+ }
475
+ }
476
+ for ((id, token) in dwell.takeDue()) fireDwellLocked(id, token)
477
+ refreshDerivedLocked()
478
+ }
479
+
480
+ /** Classifies [location] against the polygon of [entry] and applies a confirmed state change. */
481
+ private suspend fun evaluatePolygonLocked(entry: Entry, location: TrackedLocation) {
482
+ val spec = entry.spec
483
+ val vertices = spec.vertices ?: return
484
+ if (!isActive(spec)) return
485
+ entry.lastFix?.let { if (sameFix(it, location)) return } // e.g. from an OS transition, then the stream
486
+ entry.lastFix = location
487
+ val accuracy = accuracyOf(location)
488
+ val fromCenter = distanceFromCenter(spec, location)
489
+ val inside: Boolean
490
+ val certain: Boolean
491
+ if (fromCenter - accuracy > spec.radius) {
492
+ inside = false
493
+ certain = true
494
+ // Certainly outside the enclosing circle: covers a late or lost OS EXIT and stale restored state.
495
+ if (entry.runtime.insideCircle) persistRuntime(entry, entry.runtime.copy(insideCircle = false))
496
+ } else {
497
+ inside = PolygonMath.contains(vertices, location.latitude, location.longitude)
498
+ certain = accuracy == 0.0 ||
499
+ PolygonMath.distanceToBoundary(vertices, location.latitude, location.longitude) >= accuracy
500
+ }
501
+ val known = entry.runtime.enteredAt != null
502
+ if (known && inside == entry.runtime.insidePolygon) {
503
+ entry.resetCandidate()
504
+ return
505
+ }
506
+ if (!certain) {
507
+ if (entry.candidate == inside) {
508
+ entry.candidateCount++
509
+ } else {
510
+ entry.candidate = inside
511
+ entry.candidateCount = 1
512
+ }
513
+ if (entry.candidateCount < CONSISTENT_FIXES) return
514
+ }
515
+ entry.resetCandidate()
516
+
517
+ val now = clock.now()
518
+ val silent = !known && !configStore.config.value.geofence.initialTriggerEntry &&
519
+ (entry.initialLatched || now <= entry.initialUntil)
520
+ entry.initialLatched = false
521
+ if (inside && silent) {
522
+ persistRuntime(entry, entry.runtime.copy(insidePolygon = true, enteredAt = ENTERED_BEFORE_MONITORING))
523
+ } else if (inside) {
524
+ persistRuntime(entry, entry.runtime.copy(insidePolygon = true, enteredAt = now))
525
+ if (spec.notifyOnEntry) notifyLocked(spec, GeofenceAction.ENTER, location)
526
+ if (spec.notifyOnDwell) dwell.arm(spec.identifier, now + spec.loiteringDelay, token = now)
527
+ } else {
528
+ dwell.cancel(spec.identifier)
529
+ persistRuntime(entry, entry.runtime.copy(insidePolygon = false, enteredAt = now))
530
+ if (known && spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, location)
531
+ }
532
+ }
533
+
534
+ // ---- dwell
535
+
536
+ /** Emits a synthesized DWELL if [id] is still inside since [token] (the `enteredAt` the timer was armed with). */
537
+ private suspend fun fireDwellLocked(id: String, token: Long) {
538
+ if (session == false || !configStore.runtime.value.enabled) return
539
+ val entry = entries[id] ?: return
540
+ val inside = if (entry.spec.isPolygon) entry.runtime.insidePolygon else entry.runtime.insideCircle
541
+ if (!inside || entry.runtime.enteredAt != token || !entry.spec.notifyOnDwell) return
542
+ notifyLocked(entry.spec, GeofenceAction.DWELL, null)
543
+ }
544
+
545
+ /** Aligns synthesized dwell timers with the current backend and the (possibly restored) runtime. */
546
+ private fun refreshDwellTimersLocked() {
547
+ val now = clock.now()
548
+ val backend = activeBackend()
549
+ for (entry in entries.values) {
550
+ val spec = entry.spec
551
+ val synthesized = spec.notifyOnDwell && (spec.isPolygon || synthesizesDwell(spec, backend))
552
+ if (!synthesized) {
553
+ dwell.cancel(spec.identifier)
554
+ continue
555
+ }
556
+ if (dwell.isArmed(spec.identifier)) continue
557
+ val enteredAt = entry.runtime.enteredAt ?: continue
558
+ val inside = if (spec.isPolygon) entry.runtime.insidePolygon else entry.runtime.insideCircle
559
+ val deadline = enteredAt + spec.loiteringDelay
560
+ // A deadline that passed while the process was gone is assumed to have fired already.
561
+ if (inside && enteredAt != ENTERED_BEFORE_MONITORING && deadline > now) {
562
+ dwell.arm(spec.identifier, deadline, token = enteredAt)
563
+ }
564
+ }
565
+ }
566
+
567
+ // ---- records
568
+
569
+ /** Creates, submits and emits a geofence record. Never throws (except cancellation). */
570
+ private suspend fun notifyLocked(spec: GeofenceSpec, action: GeofenceAction, location: TrackedLocation?) {
571
+ try {
572
+ val hit = GeofenceHit(spec.identifier, action, spec.extras)
573
+ val record = recordFactory.create(RecordEvent.GEOFENCE, location ?: bestKnownLocation(), geofence = hit)
574
+ try {
575
+ recordSink.submit(record)
576
+ } catch (e: CancellationException) {
577
+ throw e
578
+ } catch (e: Exception) {
579
+ Logger.e(TAG, "failed to submit geofence record ${record.uuid}", e)
580
+ }
581
+ Logger.i(TAG, "geofence $action '${spec.identifier}'")
582
+ events.emit(TrackingEvent.Geofence(spec.identifier, action, record, spec.extras))
583
+ } catch (e: CancellationException) {
584
+ throw e
585
+ } catch (e: Exception) {
586
+ Logger.e(TAG, "failed to record geofence $action '${spec.identifier}'", e)
587
+ }
588
+ }
589
+
590
+ private fun bestKnownLocation(): TrackedLocation? {
591
+ val fix = lastFix
592
+ val last = configStore.runtime.value.lastLocation
593
+ return when {
594
+ fix == null -> last
595
+ last == null -> fix
596
+ else -> if (fix.time >= last.time) fix else last
597
+ }
598
+ }
599
+
600
+ // ---- OS registration
601
+
602
+ private fun activeBackend(): GeofenceBackend = registeredBackend ?: providers.geofence()
603
+
604
+ /** The backend to register with; moves the registrations if the provider changed since they were made. */
605
+ private suspend fun backendLocked(): GeofenceBackend {
606
+ val current = providers.geofence()
607
+ val previous = registeredBackend
608
+ registeredBackend = current
609
+ if (previous != null && previous.kind != current.kind) {
610
+ Logger.i(TAG, "geofence backend changed ${previous.kind.wire} -> ${current.kind.wire}")
611
+ removeFromOsLocked(previous, registrableIdsLocked())
612
+ cancelRetryLocked()
613
+ if (registerAllLocked(current) && session == true) scheduleRetryLocked()
614
+ refreshDwellTimersLocked()
615
+ }
616
+ return current
617
+ }
618
+
619
+ /**
620
+ * Registers every stored geofence; the OS's initial triggers follow, so polygons open their initial window.
621
+ * Returns true when the backend answered UNAVAILABLE for a batch (see [scheduleRetryLocked]).
622
+ */
623
+ private suspend fun registerAllLocked(backend: GeofenceBackend): Boolean {
624
+ val now = clock.now()
625
+ for (entry in entries.values) openInitialWindowLocked(entry, now)
626
+ return registerLocked(backend, entries.values.map { it.spec }, throwOnError = false)
627
+ }
628
+
629
+ /**
630
+ * Registers every geofence again after [RETRY_DELAYS_MS] (the next delay on each failure), because the backend
631
+ * answered UNAVAILABLE. Google Play services answers GEOFENCE_NOT_AVAILABLE while its network location is off,
632
+ * and the plugin registers again within about a second of location services coming back on, which can be before
633
+ * Play services has switched its network location on again (CI emulator, API 29, P-P06). Without a retry the
634
+ * geofences stayed unregistered until tracking started again. Stops at the first registration without
635
+ * UNAVAILABLE, when tracking stops or starts again, or after the last delay.
636
+ */
637
+ private fun scheduleRetryLocked() {
638
+ if (retryAttempt >= RETRY_DELAYS_MS.size) {
639
+ Logger.w(TAG, "geofence backend still unavailable after ${RETRY_DELAYS_MS.size} retries; giving up until tracking starts again")
640
+ return
641
+ }
642
+ val delayMs = RETRY_DELAYS_MS[retryAttempt]
643
+ retryAttempt += 1
644
+ val attempt = retryAttempt
645
+ Logger.i(TAG, "geofence backend unavailable; registering the geofences again in ${delayMs / 1000} s (retry $attempt)")
646
+ retryJob?.cancel()
647
+ retryJob = scope.launch {
648
+ delay(delayMs)
649
+ mutex.withLock {
650
+ if (session != true || retryAttempt != attempt) return@withLock
651
+ val backend = registeredBackend ?: return@withLock
652
+ if (registerAllLocked(backend)) {
653
+ scheduleRetryLocked()
654
+ } else {
655
+ Logger.i(TAG, "geofences registered again (retry $attempt)")
656
+ retryAttempt = 0
657
+ refreshDwellTimersLocked()
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ private fun cancelRetryLocked() {
664
+ retryJob?.cancel()
665
+ retryJob = null
666
+ retryAttempt = 0
667
+ }
668
+
669
+ /**
670
+ * Starts the initial-trigger window of an unclassified polygon being registered. A fresh known fix that is
671
+ * certainly outside its enclosing circle settles the state as outside instead, so an entry soon after
672
+ * registration is not mistaken for the OS's initial trigger.
673
+ */
674
+ private suspend fun openInitialWindowLocked(entry: Entry, now: Long) {
675
+ entry.initialUntil = now + INITIAL_TRIGGER_WINDOW_MS
676
+ entry.initialLatched = false
677
+ val spec = entry.spec
678
+ if (!spec.isPolygon || entry.runtime.enteredAt != null) return
679
+ val fix = bestKnownLocation() ?: return
680
+ if (now - fix.time > INITIAL_TRIGGER_WINDOW_MS) return
681
+ if (distanceFromCenter(spec, fix) - accuracyOf(fix) > spec.radius) {
682
+ persistRuntime(entry, GeofenceRuntime(insideCircle = false, insidePolygon = false, enteredAt = now))
683
+ }
684
+ }
685
+
686
+ /** Returns true when a batch failed with UNAVAILABLE (only when [throwOnError] is false). */
687
+ private suspend fun registerLocked(backend: GeofenceBackend, specs: List<GeofenceSpec>, throwOnError: Boolean): Boolean {
688
+ val initialTriggerEntry = configStore.config.value.geofence.initialTriggerEntry
689
+ val regions = specs.mapNotNull { toOsGeofence(it, backend, initialTriggerEntry) }
690
+ var unavailable = false
691
+ for (batch in regions.chunked(BATCH_SIZE)) {
692
+ try {
693
+ backend.add(batch)
694
+ } catch (e: CancellationException) {
695
+ throw e
696
+ } catch (e: Exception) {
697
+ if (throwOnError) throw e
698
+ Logger.e(TAG, "failed to register ${batch.size} geofence(s) with ${backend.kind.wire}", e)
699
+ if (e is TrackingException && e.code == ErrorCode.UNAVAILABLE) unavailable = true
700
+ }
701
+ }
702
+ return unavailable
703
+ }
704
+
705
+ private suspend fun removeFromOsLocked(backend: GeofenceBackend, ids: List<String>) {
706
+ for (batch in ids.chunked(BATCH_SIZE)) {
707
+ try {
708
+ backend.remove(batch)
709
+ } catch (e: CancellationException) {
710
+ throw e
711
+ } catch (e: Exception) {
712
+ Logger.w(TAG, "failed to unregister ${batch.size} geofence(s) from ${backend.kind.wire}", e)
713
+ }
714
+ }
715
+ }
716
+
717
+ /** Best effort: unregisters [newIds] and re-registers the [replaced] versions after a failed add. */
718
+ private suspend fun rollbackRegistrationLocked(
719
+ backend: GeofenceBackend,
720
+ newIds: List<String>,
721
+ replaced: List<GeofenceSpec>,
722
+ ) {
723
+ removeFromOsLocked(backend, newIds)
724
+ registerLocked(backend, replaced, throwOnError = false)
725
+ }
726
+
727
+ /** Ids of the geofences that [toOsGeofence] registers. */
728
+ private fun registrableIdsLocked(): List<String> =
729
+ entries.values.filter { isActive(it.spec) }.map { it.spec.identifier }
730
+
731
+ // ---- state
732
+
733
+ private suspend fun ensureLoadedLocked() {
734
+ if (loaded) return
735
+ val specs = geofenceStore.all()
736
+ val runtimes = geofenceStore.runtimes()
737
+ // Runtime is only meaningful while tracking; stale values from a stopped session are discarded.
738
+ val keepRuntime = configStore.runtime.value.enabled
739
+ entries.clear()
740
+ for (stored in specs) {
741
+ val spec = if (stored.isPolygon && !(stored.radius > 0f)) withEnclosingCircle(stored) else stored
742
+ val runtime = runtimes[spec.identifier]
743
+ entries[spec.identifier] = Entry(spec, if (keepRuntime) runtime ?: UNKNOWN else UNKNOWN)
744
+ if (!keepRuntime && runtime != null && runtime != UNKNOWN) persistRuntime(spec.identifier, UNKNOWN)
745
+ }
746
+ loaded = true
747
+ refreshDerivedLocked()
748
+ }
749
+
750
+ private fun refreshDerivedLocked() {
751
+ val polygons = entries.values.filter { it.spec.isPolygon && isActive(it.spec) }
752
+ wantsFixes = polygons.isNotEmpty() ||
753
+ entries.values.any { !it.spec.isPolygon && !it.observed && it.runtime.insideCircle && isActive(it.spec) }
754
+ needs.value = session == true && polygons.any { it.runtime.insideCircle || it.runtime.insidePolygon }
755
+ }
756
+
757
+ private suspend fun persistRuntime(entry: Entry, runtime: GeofenceRuntime) {
758
+ entry.runtime = runtime
759
+ persistRuntime(entry.spec.identifier, runtime)
760
+ }
761
+
762
+ private suspend fun persistRuntime(id: String, runtime: GeofenceRuntime) {
763
+ try {
764
+ geofenceStore.setRuntime(id, runtime)
765
+ } catch (e: CancellationException) {
766
+ throw e
767
+ } catch (e: Exception) {
768
+ Logger.w(TAG, "failed to persist runtime of geofence '$id'", e)
769
+ }
770
+ }
771
+
772
+ private companion object {
773
+ const val TAG = "LT.Geofences"
774
+
775
+ /** Geofences per OS registration call. */
776
+ const val BATCH_SIZE = 25
777
+
778
+ /** Delays of the registrations after the backend answered UNAVAILABLE (about 8.7 minutes in all). */
779
+ val RETRY_DELAYS_MS = longArrayOf(10_000L, 30_000L, 60_000L, 120_000L, 300_000L)
780
+
781
+ /** Consecutive agreeing fixes that confirm a polygon transition when accuracy straddles the boundary. */
782
+ const val CONSISTENT_FIXES = 2
783
+
784
+ /** How long after registration the OS's initial circle ENTER is expected. */
785
+ const val INITIAL_TRIGGER_WINDOW_MS = 120_000L
786
+
787
+ /** A fix older than the last evaluated one by up to this much is out of order and skipped. */
788
+ const val MAX_REORDER_MS = 120_000L
789
+
790
+ /** `enteredAt` of a polygon found inside by its silent initial classification (no ENTER, no DWELL). */
791
+ const val ENTERED_BEFORE_MONITORING = 0L
792
+
793
+ /** Longest identifier accepted (the GMS / HMS request-id limit). */
794
+ const val MAX_IDENTIFIER_LENGTH = 100
795
+
796
+ /** Smallest polygon area (m²) that is not considered degenerate. */
797
+ const val MIN_POLYGON_AREA_M2 = 1.0
798
+
799
+ /** Runtime of a geofence whose state is not known. */
800
+ val UNKNOWN = GeofenceRuntime(insideCircle = false, insidePolygon = false, enteredAt = null)
801
+
802
+ fun isActive(spec: GeofenceSpec): Boolean = spec.notifyOnEntry || spec.notifyOnExit || spec.notifyOnDwell
803
+
804
+ /** Circle DWELL is synthesized when the backend cannot report it. */
805
+ fun synthesizesDwell(spec: GeofenceSpec, backend: GeofenceBackend): Boolean =
806
+ !spec.isPolygon && spec.notifyOnDwell && !backend.supportsDwell
807
+
808
+ fun sameFix(a: TrackedLocation, b: TrackedLocation): Boolean =
809
+ a.time == b.time && a.latitude == b.latitude && a.longitude == b.longitude
810
+
811
+ fun distanceFromCenter(spec: GeofenceSpec, location: TrackedLocation): Double =
812
+ PolygonMath.distanceMeters(spec.latitude, spec.longitude, location.latitude, location.longitude)
813
+
814
+ fun accuracyOf(location: TrackedLocation): Double =
815
+ location.accuracy.toDouble().takeIf { it.isFinite() && it > 0 } ?: 0.0
816
+
817
+ fun toOsGeofence(spec: GeofenceSpec, backend: GeofenceBackend, initialTriggerEntry: Boolean): OsGeofence? {
818
+ if (!isActive(spec)) return null
819
+ val loiteringDelay = spec.loiteringDelay.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
820
+ if (spec.isPolygon) {
821
+ // The circle only tells us when hit-testing is needed; the initial ENTER is always wanted.
822
+ return OsGeofence(
823
+ id = spec.identifier,
824
+ latitude = spec.latitude,
825
+ longitude = spec.longitude,
826
+ radius = spec.radius,
827
+ onEntry = true,
828
+ onExit = true,
829
+ onDwell = false,
830
+ loiteringDelayMs = loiteringDelay,
831
+ initialTriggerEntry = true,
832
+ )
833
+ }
834
+ val synthesizeDwell = synthesizesDwell(spec, backend)
835
+ return OsGeofence(
836
+ id = spec.identifier,
837
+ latitude = spec.latitude,
838
+ longitude = spec.longitude,
839
+ radius = spec.radius,
840
+ onEntry = spec.notifyOnEntry || synthesizeDwell,
841
+ onExit = spec.notifyOnExit || synthesizeDwell,
842
+ onDwell = spec.notifyOnDwell && backend.supportsDwell,
843
+ loiteringDelayMs = loiteringDelay,
844
+ initialTriggerEntry = initialTriggerEntry,
845
+ )
846
+ }
847
+
848
+ /**
849
+ * Checks [spec] and, for a polygon, fills in its enclosing circle.
850
+ *
851
+ * @throws TrackingException INVALID_ARGUMENT
852
+ */
853
+ fun validated(spec: GeofenceSpec): GeofenceSpec {
854
+ val id = spec.identifier
855
+ if (id.isBlank()) invalid("geofence identifier is required")
856
+ if (id == Constants.STATIONARY_REGION_ID) {
857
+ invalid("geofence identifier '$id' is reserved for the plugin's stationary region")
858
+ }
859
+ if (id.length > MAX_IDENTIFIER_LENGTH) {
860
+ invalid("geofence identifier is longer than $MAX_IDENTIFIER_LENGTH characters")
861
+ }
862
+ if (spec.loiteringDelay < 0) invalid("geofence '$id': loiteringDelay must be >= 0")
863
+ val vertices = spec.vertices
864
+ if (vertices == null) {
865
+ if (!PolygonMath.isValidLatLng(spec.latitude, spec.longitude)) {
866
+ invalid("geofence '$id': invalid latitude/longitude (${spec.latitude}, ${spec.longitude})")
867
+ }
868
+ if (!(spec.radius.isFinite() && spec.radius > 0f)) invalid("geofence '$id': radius must be > 0")
869
+ return spec
870
+ }
871
+ if (vertices.size < 3) invalid("geofence '$id': a polygon needs at least 3 vertices")
872
+ vertices.forEachIndexed { i, v ->
873
+ if (!PolygonMath.isValidLatLng(v.latitude, v.longitude)) {
874
+ invalid("geofence '$id': invalid vertices[$i] (${v.latitude}, ${v.longitude})")
875
+ }
876
+ }
877
+ if (vertices.maxOf { it.longitude } - vertices.minOf { it.longitude } > 180.0) {
878
+ invalid("geofence '$id': polygons crossing the anti-meridian are not supported")
879
+ }
880
+ if (vertices.distinct().size < 3 || PolygonMath.areaSquareMeters(vertices) < MIN_POLYGON_AREA_M2) {
881
+ invalid("geofence '$id': the polygon has no area")
882
+ }
883
+ return withEnclosingCircle(spec)
884
+ }
885
+
886
+ fun withEnclosingCircle(spec: GeofenceSpec): GeofenceSpec {
887
+ val circle = PolygonMath.enclosingCircle(spec.vertices ?: return spec)
888
+ return spec.copy(latitude = circle.latitude, longitude = circle.longitude, radius = circle.radius.toFloat())
889
+ }
890
+
891
+ fun invalid(message: String): Nothing = throw TrackingException(ErrorCode.INVALID_ARGUMENT, message)
892
+
893
+ fun Exception.toTrackingException(code: ErrorCode, message: String): TrackingException = when (this) {
894
+ is TrackingException -> this
895
+ is SecurityException -> TrackingException(ErrorCode.PERMISSION_DENIED, "$message: ${this.message}", this)
896
+ else -> TrackingException(code, "$message: ${this.message}", this)
897
+ }
898
+ }
899
+ }