@bricks-soft/capacitor-location-tracking 8.0.1 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +476 -147
  4. package/android/build.gradle +3 -1
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  7. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  15. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  17. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  18. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  19. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  20. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  23. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  26. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  33. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  34. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  37. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  39. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  40. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  42. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  43. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  44. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  51. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  53. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  54. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  55. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  56. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  57. package/dist/esm/definitions.d.ts +117 -8
  58. package/dist/esm/definitions.js.map +1 -1
  59. package/dist/esm/events.d.ts +8 -2
  60. package/dist/esm/events.js +7 -1
  61. package/dist/esm/events.js.map +1 -1
  62. package/dist/esm/index.d.ts +1 -1
  63. package/dist/esm/index.js +1 -1
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/web/config.js +10 -0
  66. package/dist/esm/web/config.js.map +1 -1
  67. package/dist/esm/web/device.d.ts +1 -1
  68. package/dist/esm/web/device.js +8 -1
  69. package/dist/esm/web/device.js.map +1 -1
  70. package/dist/esm/web.d.ts +0 -8
  71. package/dist/esm/web.js +12 -3
  72. package/dist/esm/web.js.map +1 -1
  73. package/dist/plugin.cjs.js +39 -5
  74. package/dist/plugin.cjs.js.map +1 -1
  75. package/dist/plugin.js +39 -5
  76. package/dist/plugin.js.map +1 -1
  77. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  78. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  79. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  80. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  81. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  82. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  83. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  84. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  85. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  86. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  87. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  88. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  89. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  90. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  91. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  92. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  93. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  94. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  95. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  96. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  97. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  98. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  99. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  100. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  101. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  102. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  103. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  104. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  105. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  106. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  107. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  108. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  109. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  110. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  111. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  112. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  113. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  114. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  115. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  116. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  117. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  118. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  119. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  120. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  121. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  122. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  123. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  124. package/package.json +10 -4
@@ -0,0 +1,453 @@
1
+ import Foundation
2
+
3
+ protocol GeofenceBackend: AnyObject {
4
+ func add(_ regions: [OsGeofence])
5
+ func restore(_ regions: [OsGeofence])
6
+ func remove(_ ids: [String])
7
+ func retain(_ ids: Set<String>)
8
+ func holdSession(_ always: Bool)
9
+ }
10
+
11
+ final class GeofenceManager {
12
+ static let maxGeofences = 20
13
+ static let initialTriggerWindow = 120000.0
14
+ static let maxReorder = 120000.0
15
+ static let consistentFixes = 2
16
+ static let enteredBeforeMonitoring = 0.0
17
+ static let maxIdentifierLength = 100
18
+ static let minPolygonArea = 1.0
19
+ static let reservedIdentifier = "__lt_stationary__"
20
+
21
+ private final class Entry {
22
+ let spec: GeofenceSpec
23
+ var runtime: GeofenceRuntime
24
+ var observed = false
25
+ var initialUntil = -Double.infinity
26
+ var initialLatched = false
27
+ var lastFix: TrackedLocation?
28
+ var candidate: Bool?
29
+ var candidateCount = 0
30
+ init(_ spec: GeofenceSpec, _ runtime: GeofenceRuntime) { self.spec = spec; self.runtime = runtime }
31
+ func resetCandidate() { candidate = nil; candidateCount = 0 }
32
+ }
33
+
34
+ private let store: GeofenceStore
35
+ private let backend: GeofenceBackend
36
+ private let now: () -> Double
37
+ private let initialTriggerEntry: () -> Bool
38
+ private let enabled: () -> Bool
39
+ private let storedLocation: () -> TrackedLocation?
40
+ private let notify: (GeofenceSpec, GeofenceAction, TrackedLocation?) -> Void
41
+ private let changed: ([GeofenceSpec], [String]) -> Void
42
+ private let log: (String, String) -> Void
43
+ private var entries: [String: Entry] = [:]
44
+ private var order: [String] = []
45
+ private var session: Bool?
46
+ private var loaded = false
47
+ private var wantsFixes = false
48
+ private var lastFix: TrackedLocation?
49
+ private var lastStreamFix: TrackedLocation?
50
+ private(set) var needsContinuousLocation = false
51
+ private(set) var suspended = false
52
+ private var always = false
53
+ var demandChanged: ((Bool) -> Void)?
54
+ var blocked: (() -> Void)?
55
+ private(set) var dwell: DwellTracker!
56
+
57
+ init(store: GeofenceStore, backend: GeofenceBackend, queue: DispatchQueue, now: @escaping () -> Double, automaticTimers: Bool = true, initialTriggerEntry: @escaping () -> Bool, enabled: @escaping () -> Bool, storedLocation: @escaping () -> TrackedLocation?, notify: @escaping (GeofenceSpec, GeofenceAction, TrackedLocation?) -> Void, changed: @escaping ([GeofenceSpec], [String]) -> Void, log: @escaping (String, String) -> Void = { _, _ in }) {
58
+ self.store = store; self.backend = backend; self.now = now; self.initialTriggerEntry = initialTriggerEntry; self.enabled = enabled
59
+ self.storedLocation = storedLocation; self.notify = notify; self.changed = changed; self.log = log
60
+ dwell = DwellTracker(queue: queue, now: now, automatic: automaticTimers) { [weak self] id, token in self?.fireDwell(id, token) }
61
+ }
62
+
63
+ func add(_ specs: [GeofenceSpec]) throws {
64
+ guard !specs.isEmpty else { return }
65
+ var incoming: [String: GeofenceSpec] = [:], ids: [String] = []
66
+ for spec in specs {
67
+ let valid = try Self.validated(spec)
68
+ if incoming[valid.identifier] == nil { ids.append(valid.identifier) }
69
+ incoming[valid.identifier] = valid
70
+ }
71
+ let list = ids.map { incoming[$0]! }
72
+ try ensureLoaded()
73
+ let newIds = ids.filter { entries[$0] == nil }
74
+ let total = entries.count + newIds.count
75
+ if total > Self.maxGeofences { throw TrackingError("TOO_MANY_GEOFENCES", "at most \(Self.maxGeofences) geofences are supported (this would make \(total))") }
76
+ let replaced = ids.compactMap { entries[$0]?.spec }
77
+ let register = enabled() && !suspended
78
+ if register {
79
+ let active = Set(list.filter(\.isActive).map(\.identifier))
80
+ let deactivated = replaced.filter(\.isActive).map(\.identifier).filter { !active.contains($0) }
81
+ if !deactivated.isEmpty { backend.remove(deactivated) }
82
+ self.register(list)
83
+ }
84
+ do { try store.upsert(list) }
85
+ catch {
86
+ if register { backend.remove(newIds); self.register(replaced) }
87
+ throw TrackingError("IO_ERROR", "failed to store geofences")
88
+ }
89
+ let time = now()
90
+ for spec in list {
91
+ let previous = entries[spec.identifier]
92
+ dwell.cancel(spec.identifier)
93
+ let entry = Entry(spec, .unknown)
94
+ if previous == nil { order.append(spec.identifier) }
95
+ entries[spec.identifier] = entry
96
+ if let previous, previous.runtime != .unknown { persist(spec.identifier, .unknown) }
97
+ if register { openInitialWindow(entry, time) }
98
+ }
99
+ refreshDerived()
100
+ reconcileSession()
101
+ if session == true && suspended && list.contains(where: \.isActive) { blocked?() }
102
+ log("info", "added \(list.count) geofence(s): \(ids.joined(separator: ", "))")
103
+ changed(list, [])
104
+ }
105
+
106
+ func remove(_ ids: [String]) throws {
107
+ guard !ids.isEmpty else { return }
108
+ try ensureLoaded()
109
+ var requested: [String] = []
110
+ for id in ids where !requested.contains(id) { requested.append(id) }
111
+ let existing = requested.filter { entries[$0] != nil }
112
+ guard !existing.isEmpty else { return }
113
+ do { try store.remove(existing) } catch { throw TrackingError("IO_ERROR", "failed to remove geofences") }
114
+ if enabled() { backend.remove(existing.filter { entries[$0]!.spec.isActive }) }
115
+ for id in existing { dwell.cancel(id); entries.removeValue(forKey: id) }
116
+ order.removeAll { existing.contains($0) }
117
+ refreshDerived()
118
+ reconcileSession()
119
+ changed([], existing)
120
+ }
121
+
122
+ func removeAll() throws {
123
+ try ensureLoaded()
124
+ let ids = order, registered = registrableIds()
125
+ do { try store.removeAll() } catch { throw TrackingError("IO_ERROR", "failed to remove geofences") }
126
+ if enabled() { backend.remove(registered) }
127
+ dwell.cancelAll(); entries.removeAll(); order.removeAll()
128
+ refreshDerived()
129
+ reconcileSession()
130
+ guard !ids.isEmpty else { return }
131
+ changed([], ids)
132
+ }
133
+
134
+ func list() throws -> [GeofenceSpec] { try store.all() }
135
+ func get(_ id: String) throws -> GeofenceSpec? { try store.get(id) }
136
+
137
+ func onTrackingStarted(always: Bool = false) {
138
+ session = true
139
+ self.always = always
140
+ do { try ensureLoaded() } catch { log("error", "cannot load geofences; none registered"); return }
141
+ reconcileSession()
142
+ registerAll()
143
+ refreshDwellTimers()
144
+ refreshDerived()
145
+ if suspended && !registrableIds().isEmpty { blocked?() }
146
+ }
147
+
148
+ private func reconcileSession() {
149
+ guard session == true else { return }
150
+ backend.holdSession(always && !registrableIds().isEmpty)
151
+ }
152
+
153
+ func onTrackingStopped() {
154
+ session = false
155
+ dwell.cancelAll()
156
+ lastStreamFix = nil
157
+ backend.holdSession(false)
158
+ do { try ensureLoaded() } catch { refreshDerived(); return }
159
+ backend.remove(registrableIds())
160
+ backend.retain([])
161
+ for entry in entries.values {
162
+ entry.observed = false; entry.initialLatched = false; entry.lastFix = nil; entry.resetCandidate()
163
+ if entry.runtime != .unknown { persist(entry, .unknown) }
164
+ }
165
+ refreshDerived()
166
+ }
167
+
168
+ func releaseStale() { backend.retain([]) }
169
+
170
+ func setSuspended(_ value: Bool) {
171
+ guard value != suspended else { return }
172
+ suspended = value
173
+ guard session == true else { return }
174
+ if value {
175
+ dwell.cancelAll()
176
+ backend.remove(registrableIds())
177
+ if !registrableIds().isEmpty { blocked?() }
178
+ } else {
179
+ registerAll()
180
+ refreshDwellTimers()
181
+ }
182
+ refreshDerived()
183
+ }
184
+
185
+ func onLocation(_ location: TrackedLocation) {
186
+ if lastFix == nil || !location.isOlder(than: lastFix!) { lastFix = location }
187
+ if session == false || !enabled() || suspended { return }
188
+ if loaded && !wantsFixes && !dwell.hasArmed { return }
189
+ do { try handleLocation(location) } catch { log("error", "failed to process location for geofences") }
190
+ }
191
+
192
+ func onTransitions(_ transitions: [OsGeofenceTransition]) {
193
+ guard !transitions.isEmpty else { return }
194
+ if session == false || !enabled() || suspended { log("warn", "ignoring \(transitions.count) geofence transition(s): tracking is stopped"); return }
195
+ do { try ensureLoaded() } catch { log("error", "cannot load geofences; dropping \(transitions.count) transition(s)"); return }
196
+ for transition in transitions {
197
+ guard let entry = entries[transition.id] else { log("warn", "ignoring \(transition.action.rawValue) for unknown geofence '\(transition.id)'"); continue }
198
+ if transition.initial && !(transition.action == .exit && entry.runtime.insideCircle && (entry.spec.isPolygon || !entry.observed)) { continue }
199
+ if entry.spec.isPolygon { onPolygonCircleTransition(entry, transition) } else { onCircleTransition(entry, transition) }
200
+ }
201
+ refreshDerived()
202
+ }
203
+
204
+ func fireDue() { dwell.fireDue() }
205
+
206
+ private func onCircleTransition(_ entry: Entry, _ transition: OsGeofenceTransition) {
207
+ let spec = entry.spec
208
+ let synthesize = spec.notifyOnDwell
209
+ let wasObserved = entry.observed
210
+ entry.observed = true
211
+ switch transition.action {
212
+ case .enter:
213
+ let tracksState = (spec.notifyOnEntry || synthesize) && (spec.notifyOnExit || synthesize)
214
+ if tracksState && wasObserved && entry.runtime.insideCircle { return }
215
+ let time = now()
216
+ persist(entry, GeofenceRuntime(insideCircle: true, insidePolygon: false, enteredAt: time))
217
+ if spec.notifyOnEntry { record(spec, .enter, transition.location) }
218
+ if synthesize { dwell.arm(spec.identifier, deadline: time + spec.loiteringDelay, token: time) }
219
+ case .exit:
220
+ dwell.cancel(spec.identifier)
221
+ persist(entry, .unknown)
222
+ if spec.notifyOnExit { record(spec, .exit, transition.location) }
223
+ case .dwell:
224
+ dwell.cancel(spec.identifier)
225
+ if !entry.runtime.insideCircle { persist(entry, GeofenceRuntime(insideCircle: true, insidePolygon: false, enteredAt: now())) }
226
+ if spec.notifyOnDwell { record(spec, .dwell, transition.location) }
227
+ }
228
+ }
229
+
230
+ private func reconcileCircle(_ entry: Entry, _ location: TrackedLocation) {
231
+ let spec = entry.spec
232
+ let accuracy = Self.accuracy(location), fromCenter = Self.distanceFromCenter(spec, location)
233
+ if fromCenter - accuracy > spec.radius {
234
+ entry.observed = true
235
+ dwell.cancel(spec.identifier)
236
+ persist(entry, .unknown)
237
+ if spec.notifyOnExit { record(spec, .exit, location) }
238
+ } else if fromCenter + accuracy <= spec.radius {
239
+ entry.observed = true
240
+ }
241
+ }
242
+
243
+ private func onPolygonCircleTransition(_ entry: Entry, _ transition: OsGeofenceTransition) {
244
+ switch transition.action {
245
+ case .enter:
246
+ var runtime = entry.runtime
247
+ runtime.insideCircle = true
248
+ if runtime.enteredAt == nil {
249
+ if now() <= entry.initialUntil { entry.initialLatched = true }
250
+ else { runtime.insidePolygon = false; runtime.enteredAt = now() }
251
+ }
252
+ if runtime != entry.runtime { persist(entry, runtime) }
253
+ guard let location = transition.location else { return }
254
+ if lastStreamFix == nil || !location.isOlder(than: lastStreamFix!) { evaluatePolygon(entry, location) }
255
+ case .exit:
256
+ let wasInside = entry.runtime.insidePolygon
257
+ let since = wasInside ? now() : (entry.runtime.enteredAt ?? now())
258
+ dwell.cancel(entry.spec.identifier)
259
+ entry.resetCandidate()
260
+ entry.initialLatched = false
261
+ persist(entry, GeofenceRuntime(insideCircle: false, insidePolygon: false, enteredAt: since))
262
+ if wasInside && entry.spec.notifyOnExit { record(entry.spec, .exit, transition.location) }
263
+ case .dwell:
264
+ break
265
+ }
266
+ }
267
+
268
+ private func handleLocation(_ location: TrackedLocation) throws {
269
+ if session == false || !enabled() { return }
270
+ try ensureLoaded()
271
+ if let previous = lastStreamFix {
272
+ let olderBy = -Geo.elapsed(previous, location)
273
+ if (olderBy >= 1 && olderBy <= Self.maxReorder) || (olderBy == 0 && Self.sameFix(previous, location)) { return }
274
+ }
275
+ lastStreamFix = location
276
+ for id in order {
277
+ guard let entry = entries[id] else { continue }
278
+ if entry.spec.isPolygon { evaluatePolygon(entry, location) }
279
+ else if !entry.observed && entry.runtime.insideCircle && entry.spec.isActive { reconcileCircle(entry, location) }
280
+ }
281
+ for (id, token) in dwell.takeDue() { fireDwell(id, token) }
282
+ refreshDerived()
283
+ }
284
+
285
+ private func evaluatePolygon(_ entry: Entry, _ location: TrackedLocation) {
286
+ let spec = entry.spec
287
+ guard let vertices = spec.vertices, spec.isActive else { return }
288
+ if let last = entry.lastFix, Self.sameFix(last, location) { return }
289
+ entry.lastFix = location
290
+ let accuracy = Self.accuracy(location), fromCenter = Self.distanceFromCenter(spec, location)
291
+ let inside: Bool, certain: Bool
292
+ if fromCenter - accuracy > spec.radius {
293
+ inside = false; certain = true
294
+ if entry.runtime.insideCircle { var runtime = entry.runtime; runtime.insideCircle = false; persist(entry, runtime) }
295
+ } else {
296
+ inside = PolygonMath.contains(vertices, location.latitude, location.longitude)
297
+ certain = accuracy == 0 || PolygonMath.distanceToBoundary(vertices, location.latitude, location.longitude) >= accuracy
298
+ }
299
+ let known = entry.runtime.enteredAt != nil
300
+ if known && inside == entry.runtime.insidePolygon { entry.resetCandidate(); return }
301
+ if !certain {
302
+ if entry.candidate == inside { entry.candidateCount += 1 } else { entry.candidate = inside; entry.candidateCount = 1 }
303
+ if entry.candidateCount < Self.consistentFixes { return }
304
+ }
305
+ entry.resetCandidate()
306
+ let time = now()
307
+ let silent = !known && !initialTriggerEntry() && (entry.initialLatched || time <= entry.initialUntil)
308
+ entry.initialLatched = false
309
+ var runtime = entry.runtime
310
+ if inside && silent {
311
+ runtime.insidePolygon = true; runtime.enteredAt = Self.enteredBeforeMonitoring
312
+ persist(entry, runtime)
313
+ } else if inside {
314
+ runtime.insidePolygon = true; runtime.enteredAt = time
315
+ persist(entry, runtime)
316
+ if spec.notifyOnEntry { record(spec, .enter, location) }
317
+ if spec.notifyOnDwell { dwell.arm(spec.identifier, deadline: time + spec.loiteringDelay, token: time) }
318
+ } else {
319
+ dwell.cancel(spec.identifier)
320
+ runtime.insidePolygon = false; runtime.enteredAt = time
321
+ persist(entry, runtime)
322
+ if known && spec.notifyOnExit { record(spec, .exit, location) }
323
+ }
324
+ }
325
+
326
+ private func fireDwell(_ id: String, _ token: Double) {
327
+ if session == false || !enabled() || suspended { return }
328
+ guard let entry = entries[id] else { return }
329
+ let inside = entry.spec.isPolygon ? entry.runtime.insidePolygon : entry.runtime.insideCircle
330
+ guard inside, entry.runtime.enteredAt == token, entry.spec.notifyOnDwell else { return }
331
+ record(entry.spec, .dwell, nil)
332
+ }
333
+
334
+ private func refreshDwellTimers() {
335
+ let time = now()
336
+ for id in order {
337
+ guard let entry = entries[id] else { continue }
338
+ let spec = entry.spec
339
+ guard spec.notifyOnDwell else { dwell.cancel(id); continue }
340
+ if dwell.isArmed(id) { continue }
341
+ guard let enteredAt = entry.runtime.enteredAt else { continue }
342
+ let inside = spec.isPolygon ? entry.runtime.insidePolygon : entry.runtime.insideCircle
343
+ let deadline = enteredAt + spec.loiteringDelay
344
+ if inside && enteredAt != Self.enteredBeforeMonitoring && deadline > time { dwell.arm(id, deadline: deadline, token: enteredAt) }
345
+ }
346
+ }
347
+
348
+ private func record(_ spec: GeofenceSpec, _ action: GeofenceAction, _ location: TrackedLocation?) {
349
+ log("info", "geofence \(action.rawValue) '\(spec.identifier)'")
350
+ notify(spec, action, location ?? bestKnown())
351
+ }
352
+
353
+ private func bestKnown() -> TrackedLocation? {
354
+ let stored = storedLocation()
355
+ guard let fix = lastFix else { return stored }
356
+ guard let stored else { return fix }
357
+ return fix.isOlder(than: stored) ? stored : fix
358
+ }
359
+
360
+ private func registerAll() {
361
+ let time = now()
362
+ for id in order { if let entry = entries[id] { openInitialWindow(entry, time) } }
363
+ backend.retain(Set(registrableIds()))
364
+ guard !suspended else { return }
365
+ register(order.compactMap { entries[$0]?.spec }, restoring: true)
366
+ }
367
+
368
+ private func openInitialWindow(_ entry: Entry, _ time: Double) {
369
+ entry.initialUntil = time + Self.initialTriggerWindow
370
+ entry.initialLatched = false
371
+ let spec = entry.spec
372
+ guard spec.isPolygon, entry.runtime.enteredAt == nil, let fix = bestKnown(), time - fix.time <= Self.initialTriggerWindow else { return }
373
+ if Self.distanceFromCenter(spec, fix) - Self.accuracy(fix) > spec.radius { persist(entry, GeofenceRuntime(insideCircle: false, insidePolygon: false, enteredAt: time)) }
374
+ }
375
+
376
+ private func register(_ specs: [GeofenceSpec], restoring: Bool = false) {
377
+ let trigger = initialTriggerEntry()
378
+ let regions = specs.compactMap { Self.osGeofence($0, initialTriggerEntry: trigger) }
379
+ guard !regions.isEmpty else { return }
380
+ if restoring { backend.restore(regions) } else { backend.add(regions) }
381
+ }
382
+
383
+ private func registrableIds() -> [String] { order.filter { entries[$0]?.spec.isActive == true } }
384
+
385
+ private func ensureLoaded() throws {
386
+ if loaded { return }
387
+ let specs = try store.all(), runtimes = try store.runtimes()
388
+ let keep = enabled()
389
+ entries.removeAll(); order.removeAll()
390
+ for stored in specs {
391
+ let spec = stored.isPolygon && !(stored.radius > 0) ? Self.withEnclosingCircle(stored) : stored
392
+ let runtime = runtimes[spec.identifier]
393
+ entries[spec.identifier] = Entry(spec, keep ? runtime ?? .unknown : .unknown)
394
+ order.append(spec.identifier)
395
+ if !keep, let runtime, runtime != .unknown { persist(spec.identifier, .unknown) }
396
+ }
397
+ loaded = true
398
+ refreshDerived()
399
+ }
400
+
401
+ private func refreshDerived() {
402
+ let polygons = order.compactMap { entries[$0] }.filter { $0.spec.isPolygon && $0.spec.isActive }
403
+ wantsFixes = !polygons.isEmpty || entries.values.contains { !$0.spec.isPolygon && !$0.observed && $0.runtime.insideCircle && $0.spec.isActive }
404
+ let needs = session == true && !suspended && polygons.contains { $0.runtime.insideCircle || $0.runtime.insidePolygon }
405
+ if needs != needsContinuousLocation { needsContinuousLocation = needs; demandChanged?(needs) }
406
+ }
407
+
408
+ private func persist(_ entry: Entry, _ runtime: GeofenceRuntime) { entry.runtime = runtime; persist(entry.spec.identifier, runtime) }
409
+ private func persist(_ id: String, _ runtime: GeofenceRuntime) {
410
+ do { try store.setRuntime(id, runtime) } catch { log("warn", "failed to persist runtime of geofence '\(id)'") }
411
+ }
412
+
413
+ static func sameFix(_ a: TrackedLocation, _ b: TrackedLocation) -> Bool { a.time == b.time && a.latitude == b.latitude && a.longitude == b.longitude }
414
+ static func distanceFromCenter(_ spec: GeofenceSpec, _ location: TrackedLocation) -> Double { PolygonMath.distanceMeters(spec.latitude, spec.longitude, location.latitude, location.longitude) }
415
+ static func accuracy(_ location: TrackedLocation) -> Double { location.accuracy.isFinite && location.accuracy > 0 ? location.accuracy : 0 }
416
+
417
+ static func osGeofence(_ spec: GeofenceSpec, initialTriggerEntry: Bool) -> OsGeofence? {
418
+ guard spec.isActive else { return nil }
419
+ if spec.isPolygon { return OsGeofence(id: spec.identifier, latitude: spec.latitude, longitude: spec.longitude, radius: spec.radius, onEntry: true, onExit: true, initialTriggerEntry: true) }
420
+ return OsGeofence(id: spec.identifier, latitude: spec.latitude, longitude: spec.longitude, radius: spec.radius, onEntry: spec.notifyOnEntry || spec.notifyOnDwell, onExit: spec.notifyOnExit || spec.notifyOnDwell, initialTriggerEntry: initialTriggerEntry)
421
+ }
422
+
423
+ static func validated(_ spec: GeofenceSpec) throws -> GeofenceSpec {
424
+ let id = spec.identifier
425
+ if id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { throw invalid("geofence identifier is required") }
426
+ if id == reservedIdentifier { throw invalid("geofence identifier '\(id)' is reserved") }
427
+ if id.count > maxIdentifierLength { throw invalid("geofence identifier is longer than \(maxIdentifierLength) characters") }
428
+ if spec.loiteringDelay < 0 { throw invalid("geofence '\(id)': loiteringDelay must be >= 0") }
429
+ guard let vertices = spec.vertices else {
430
+ if !PolygonMath.isValidLatLng(spec.latitude, spec.longitude) { throw invalid("geofence '\(id)': invalid latitude/longitude (\(spec.latitude), \(spec.longitude))") }
431
+ if !(spec.radius.isFinite && spec.radius > 0) { throw invalid("geofence '\(id)': radius must be > 0") }
432
+ return spec
433
+ }
434
+ if vertices.count < 3 { throw invalid("geofence '\(id)': a polygon needs at least 3 vertices") }
435
+ for (index, vertex) in vertices.enumerated() where !PolygonMath.isValidLatLng(vertex.latitude, vertex.longitude) {
436
+ throw invalid("geofence '\(id)': invalid vertices[\(index)] (\(vertex.latitude), \(vertex.longitude))")
437
+ }
438
+ let longitudes = vertices.map(\.longitude)
439
+ if longitudes.max()! - longitudes.min()! > 180 { throw invalid("geofence '\(id)': polygons crossing the anti-meridian are not supported") }
440
+ if Set(vertices).count < 3 || PolygonMath.areaSquareMeters(vertices) < minPolygonArea { throw invalid("geofence '\(id)': the polygon has no area") }
441
+ return withEnclosingCircle(spec)
442
+ }
443
+
444
+ static func withEnclosingCircle(_ spec: GeofenceSpec) -> GeofenceSpec {
445
+ guard let vertices = spec.vertices else { return spec }
446
+ let circle = PolygonMath.enclosingCircle(vertices)
447
+ var result = spec
448
+ result.latitude = circle.latitude; result.longitude = circle.longitude; result.radius = Double(Float(circle.radius))
449
+ return result
450
+ }
451
+
452
+ private static func invalid(_ message: String) -> TrackingError { TrackingError("INVALID_ARGUMENT", message) }
453
+ }
@@ -0,0 +1,207 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ struct MonitoredCondition: Equatable {
5
+ var state: MonitorState
6
+ var latitude: Double
7
+ var longitude: Double
8
+ var radius: Double
9
+ }
10
+
11
+ struct MonitorEventValue {
12
+ var id: String
13
+ var state: MonitorState
14
+ var diagnostics: [(String, String)]
15
+ }
16
+
17
+ protocol MonitorClient: AnyObject {
18
+ func identifiers() async -> [String]
19
+ func condition(_ id: String) async -> MonitoredCondition?
20
+ func add(_ region: OsGeofence) async
21
+ func remove(_ id: String) async
22
+ func consume(_ handler: @escaping (MonitorEventValue) -> Void) async throws
23
+ }
24
+
25
+ final class GeofenceMonitor: GeofenceBackend, @unchecked Sendable {
26
+ static let defaultName = "BricksSoftLocationTrackingGeofences"
27
+ private static var instance: GeofenceMonitor?
28
+ @available(iOS 17.0, *)
29
+ static func shared(queue: DispatchQueue, authorization: @escaping () -> CLAuthorizationStatus) -> GeofenceMonitor {
30
+ if let instance { return instance }
31
+ let created = GeofenceMonitor(queue: queue, authorization: authorization) { await CoreLocationMonitorClient(name: defaultName) }
32
+ instance = created
33
+ return created
34
+ }
35
+ private let queue: DispatchQueue
36
+ private let authorization: () -> CLAuthorizationStatus
37
+ private let factory: () async -> MonitorClient
38
+ private var client: Task<MonitorClient, Never>?
39
+ private var tail: Task<Void, Never>?
40
+ private var session: AnyObject?
41
+ let adapter = GeofenceEventAdapter()
42
+ var transitions: (([OsGeofenceTransition]) -> Void)?
43
+ var failures: (([MonitorFailure]) -> Void)?
44
+ var isOpen: Bool { client != nil }
45
+
46
+ init(queue: DispatchQueue, authorization: @escaping () -> CLAuthorizationStatus, factory: @escaping () async -> MonitorClient) {
47
+ self.queue = queue; self.authorization = authorization; self.factory = factory
48
+ }
49
+
50
+ private func onQueue(_ body: @escaping () -> Void) async {
51
+ await withCheckedContinuation { continuation in queue.async { body(); continuation.resume() } }
52
+ }
53
+
54
+ private func open() -> Task<MonitorClient, Never>? {
55
+ if let client { return client }
56
+ guard authorization() != .notDetermined else { return nil }
57
+ let factory = self.factory
58
+ let opened = Task { await factory() }
59
+ client = opened
60
+ tail = Task { [weak self] in
61
+ let client = await opened.value
62
+ var persisted: [(String, MonitorState)] = []
63
+ for id in await client.identifiers() { if let condition = await client.condition(id) { persisted.append((id, condition.state)) } }
64
+ await self?.onQueue { persisted.forEach { self?.adapter.seed($0.0, $0.1) } }
65
+ Task { [weak self] in
66
+ do {
67
+ try await client.consume { value in self?.queue.async { self?.handle(value) } }
68
+ } catch {
69
+ self?.queue.async { self?.failures?([MonitorFailure(id: "", code: "UNAVAILABLE", message: "geofence monitoring stopped: " + error.localizedDescription)]) }
70
+ }
71
+ }
72
+ }
73
+ return opened
74
+ }
75
+
76
+ private func handle(_ value: MonitorEventValue) {
77
+ publish(adapter.handle(value.id, value.state, failures: value.diagnostics))
78
+ }
79
+
80
+ private func publish(_ result: ([OsGeofenceTransition], [MonitorFailure])) {
81
+ if !result.1.isEmpty { failures?(result.1) }
82
+ if !result.0.isEmpty { transitions?(result.0) }
83
+ }
84
+
85
+ private func enqueue(_ operation: @escaping (MonitorClient) async -> Void) {
86
+ guard let opened = open() else { return }
87
+ let previous = tail
88
+ tail = Task {
89
+ await previous?.value
90
+ await operation(await opened.value)
91
+ }
92
+ }
93
+
94
+ func whenIdle(_ completion: @escaping () -> Void) {
95
+ guard client != nil else { completion(); return }
96
+ enqueue { [weak self] _ in await self?.onQueue(completion) }
97
+ }
98
+
99
+ func add(_ regions: [OsGeofence]) {
100
+ guard open() != nil else { return }
101
+ for region in regions {
102
+ adapter.expect(region.id)
103
+ enqueue { [weak self] client in await self?.register(region, client) }
104
+ }
105
+ }
106
+
107
+ func restore(_ regions: [OsGeofence]) {
108
+ guard open() != nil else { return }
109
+ for region in regions {
110
+ adapter.expect(region.id)
111
+ enqueue { [weak self] client in
112
+ guard let self else { return }
113
+ if let saved = await client.condition(region.id), Self.same(saved, region) {
114
+ await self.onQueue { self.publish(self.adapter.restored(region.id, initialTriggerEntry: region.initialTriggerEntry, state: saved.state)) }
115
+ } else {
116
+ await self.register(region, client)
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ private func register(_ region: OsGeofence, _ client: MonitorClient) async {
123
+ let saved = await client.condition(region.id)
124
+ let current = saved.flatMap { Self.same($0, region) && ($0.state == .satisfied || $0.state == .unsatisfied) ? $0.state : nil }
125
+ await onQueue {
126
+ if let current { self.publish(self.adapter.registered(region.id, initialTriggerEntry: region.initialTriggerEntry, current: current)) }
127
+ else { self.adapter.registered(region.id, initialTriggerEntry: region.initialTriggerEntry) }
128
+ }
129
+ await client.add(region)
130
+ }
131
+
132
+ private static func same(_ saved: MonitoredCondition, _ region: OsGeofence) -> Bool {
133
+ abs(saved.latitude - region.latitude) < 1e-9 && abs(saved.longitude - region.longitude) < 1e-9 && abs(saved.radius - region.radius) < 1e-6
134
+ }
135
+
136
+ func remove(_ ids: [String]) {
137
+ for id in ids {
138
+ adapter.removed(id)
139
+ enqueue { [weak self] client in
140
+ await client.remove(id)
141
+ await self?.onQueue { self?.adapter.removed(id) }
142
+ }
143
+ }
144
+ }
145
+
146
+ func retain(_ ids: Set<String>) {
147
+ adapter.retain(ids)
148
+ enqueue { [weak self] client in
149
+ for id in await client.identifiers() where !ids.contains(id) { await client.remove(id) }
150
+ await self?.onQueue { self?.adapter.retain(ids) }
151
+ }
152
+ }
153
+
154
+ func holdSession(_ always: Bool) {
155
+ guard #available(iOS 18.0, *) else { return }
156
+ if always { if session == nil { session = CLServiceSession(authorization: .always) } }
157
+ else { (session as? CLServiceSession)?.invalidate(); session = nil }
158
+ }
159
+ }
160
+
161
+ @available(iOS 17.0, *)
162
+ final class CoreLocationMonitorClient: MonitorClient {
163
+ private let monitor: CLMonitor
164
+ init(name: String) async { monitor = await CLMonitor(name) }
165
+
166
+ func identifiers() async -> [String] { await monitor.identifiers }
167
+
168
+ func condition(_ id: String) async -> MonitoredCondition? {
169
+ guard let record = await monitor.record(for: id), let circle = record.condition as? CLMonitor.CircularGeographicCondition else { return nil }
170
+ return MonitoredCondition(state: Self.state(record.lastEvent.state), latitude: circle.center.latitude, longitude: circle.center.longitude, radius: circle.radius)
171
+ }
172
+
173
+ func add(_ region: OsGeofence) async {
174
+ let condition = CLMonitor.CircularGeographicCondition(center: CLLocationCoordinate2D(latitude: region.latitude, longitude: region.longitude), radius: region.radius)
175
+ await monitor.add(condition, identifier: region.id, assuming: .unknown)
176
+ }
177
+
178
+ func remove(_ id: String) async { await monitor.remove(id) }
179
+
180
+ func consume(_ handler: @escaping (MonitorEventValue) -> Void) async throws {
181
+ for try await event in await monitor.events {
182
+ handler(MonitorEventValue(id: event.identifier, state: Self.state(event.state), diagnostics: Self.diagnostics(event)))
183
+ }
184
+ }
185
+
186
+ private static func state(_ value: CLMonitor.Event.State) -> MonitorState {
187
+ switch value {
188
+ case .satisfied: return .satisfied
189
+ case .unsatisfied: return .unsatisfied
190
+ case .unknown: return .unknown
191
+ default: return .unmonitored
192
+ }
193
+ }
194
+
195
+ private static func diagnostics(_ event: CLMonitor.Event) -> [(String, String)] {
196
+ guard #available(iOS 18.0, *) else { return [] }
197
+ var result: [(String, String)] = []
198
+ if event.conditionLimitExceeded { result.append(("TOO_MANY_GEOFENCES", "Core Location suspended monitoring because the app monitors too many conditions")) }
199
+ if event.accuracyLimited { result.append(("PERMISSION_DENIED", "Core Location suspended monitoring while location accuracy is approximate")) }
200
+ if event.authorizationDeniedGlobally { result.append(("LOCATION_DISABLED", "Core Location suspended monitoring while Location Services are off")) }
201
+ else if event.authorizationDenied || event.authorizationRestricted { result.append(("PERMISSION_DENIED", "Core Location suspended monitoring because location authorization is denied")) }
202
+ if event.conditionUnsupported { result.append(("UNAVAILABLE", "Core Location does not support monitoring this condition")) }
203
+ if event.persistenceUnavailable { result.append(("IO_ERROR", "Core Location could not store the monitored condition")) }
204
+ if event.serviceSessionRequired { result.append(("UNAVAILABLE", "Core Location requires a service session for this app")) }
205
+ return result
206
+ }
207
+ }