@bricks-soft/capacitor-location-tracking 8.1.0 → 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.
- package/BricksSoftCapacitorLocationTracking.podspec +18 -0
- package/Package.swift +13 -0
- package/README.md +107 -29
- package/android/build.gradle +3 -1
- package/dist/esm/definitions.d.ts +10 -4
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +1 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/plugin.cjs.js +1 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
- package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
- package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
- package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
- package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
- package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
- package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
- package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
- package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
- package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
- package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
- package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
- package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
- package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
- package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
- package/package.json +9 -3
|
@@ -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
|
+
}
|