@bglocation/capacitor 1.1.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/CapacitorBackgroundLocation.podspec +19 -0
- package/LICENSE.md +97 -0
- package/Package.swift +44 -0
- package/README.md +264 -0
- package/android/build.gradle +74 -0
- package/android/src/main/AndroidManifest.xml +37 -0
- package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
- package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
- package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
- package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
- package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
- package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
- package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
- package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
- package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
- package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
- package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
- package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
- package/dist/esm/definitions.d.ts +390 -0
- package/dist/esm/definitions.js +3 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +47 -0
- package/dist/esm/web.js +231 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/web.test.d.ts +1 -0
- package/dist/esm/web.test.js +940 -0
- package/dist/esm/web.test.js.map +1 -0
- package/dist/plugin.cjs.js +267 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +270 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
- package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
- package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
- package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
- package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
- package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
- package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
- package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
- package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
- package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
- package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
- package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
- package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
- package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
- package/package.json +75 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Provides the last known location for geofence event enrichment.
|
|
5
|
+
/// Implemented by `BGLLocationManager` to avoid creating a second CLLocationManager.
|
|
6
|
+
public protocol BGLLocationProvider: AnyObject {
|
|
7
|
+
var lastKnownLocation: CLLocation? { get }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Abstracts CLLocationManager region monitoring for testability.
|
|
11
|
+
public protocol BGLRegionMonitoring: AnyObject {
|
|
12
|
+
var delegate: CLLocationManagerDelegate? { get set }
|
|
13
|
+
var monitoredRegions: Set<CLRegion> { get }
|
|
14
|
+
func startMonitoring(for region: CLRegion)
|
|
15
|
+
func stopMonitoring(for region: CLRegion)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
extension CLLocationManager: BGLRegionMonitoring {}
|
|
19
|
+
|
|
20
|
+
/// Manages iOS geofencing via CLLocationManager region monitoring with UserDefaults persistence.
|
|
21
|
+
///
|
|
22
|
+
/// - Persistence (UserDefaults) is the source of truth for `getGeofences()`.
|
|
23
|
+
/// - CLLocationManager supports up to 20 monitored regions simultaneously.
|
|
24
|
+
/// - Region monitoring works in background and after app termination.
|
|
25
|
+
/// - `didStartMonitoringFor` / `monitoringDidFailFor` delegates handle async registration results.
|
|
26
|
+
public class BGLGeofenceManager: NSObject, CLLocationManagerDelegate {
|
|
27
|
+
|
|
28
|
+
public static let maxGeofences = 20
|
|
29
|
+
|
|
30
|
+
private let regionMonitor: BGLRegionMonitoring
|
|
31
|
+
private let defaults: UserDefaults
|
|
32
|
+
private static let defaultsKey = "bgl_geofences_json"
|
|
33
|
+
|
|
34
|
+
/// External location provider (avoids a second CLLocationManager for location reads).
|
|
35
|
+
public weak var locationProvider: BGLLocationProvider?
|
|
36
|
+
|
|
37
|
+
/// Callback fired on geofence transitions.
|
|
38
|
+
public var onGeofenceEvent: ((GeofenceEventData) -> Void)?
|
|
39
|
+
|
|
40
|
+
/// Debug logger — set by the plugin bridge to enable geofence event logging.
|
|
41
|
+
public weak var debug: BGLDebugLogger?
|
|
42
|
+
|
|
43
|
+
/// In-memory cache — invalidated on every save. Avoids repeated JSON deserialization.
|
|
44
|
+
private var cachedGeofences: [GeofenceConfig]?
|
|
45
|
+
|
|
46
|
+
/// Pending async registration callbacks keyed by region identifier.
|
|
47
|
+
private var pendingRegistrations: [String: (onSuccess: () -> Void, onError: (String) -> Void)] =
|
|
48
|
+
[:]
|
|
49
|
+
|
|
50
|
+
/// Active dwell timers keyed by region identifier.
|
|
51
|
+
private var dwellTimers: [String: DispatchSourceTimer] = [:]
|
|
52
|
+
|
|
53
|
+
/// UserDefaults key prefix for dwell enter timestamps (retroactive dwell on app wake).
|
|
54
|
+
private static let dwellTimestampPrefix = "bgl_dwell_enter_"
|
|
55
|
+
|
|
56
|
+
/// When true, CLLocationManager operations and `pendingRegistrations` access
|
|
57
|
+
/// are dispatched to the main thread. Required because Capacitor calls plugin
|
|
58
|
+
/// methods on a background serial queue that has no run loop — without dispatch,
|
|
59
|
+
/// CLLocationManager delegate callbacks are never delivered.
|
|
60
|
+
/// Disabled when a custom (mock) `regionMonitor` is injected for tests.
|
|
61
|
+
private let useMainThreadDispatch: Bool
|
|
62
|
+
|
|
63
|
+
/// Whether any geofences are currently registered.
|
|
64
|
+
public var hasGeofences: Bool {
|
|
65
|
+
!getGeofences().isEmpty
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public init(
|
|
69
|
+
defaults: UserDefaults = UserDefaults(suiteName: "dev.bglocation.geofences") ?? .standard,
|
|
70
|
+
regionMonitor: BGLRegionMonitoring? = nil,
|
|
71
|
+
useMainThreadDispatch: Bool? = nil
|
|
72
|
+
) {
|
|
73
|
+
self.defaults = defaults
|
|
74
|
+
self.useMainThreadDispatch = useMainThreadDispatch ?? (regionMonitor == nil)
|
|
75
|
+
|
|
76
|
+
if let regionMonitor = regionMonitor {
|
|
77
|
+
self.regionMonitor = regionMonitor
|
|
78
|
+
} else {
|
|
79
|
+
// CLLocationManager must be created on the main thread — delegate
|
|
80
|
+
// callbacks are delivered on the creating thread's run loop.
|
|
81
|
+
// Capacitor's plugin call queue has no run loop.
|
|
82
|
+
if Thread.isMainThread {
|
|
83
|
+
self.regionMonitor = CLLocationManager()
|
|
84
|
+
} else {
|
|
85
|
+
var mgr: CLLocationManager!
|
|
86
|
+
DispatchQueue.main.sync { mgr = CLLocationManager() }
|
|
87
|
+
self.regionMonitor = mgr
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
super.init()
|
|
91
|
+
self.regionMonitor.delegate = self
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Execute a block on the main thread (for CLLocationManager operations)
|
|
95
|
+
/// or synchronously when using a mock in tests.
|
|
96
|
+
private func dispatchOnMain(_ work: @escaping () -> Void) {
|
|
97
|
+
if useMainThreadDispatch && !Thread.isMainThread {
|
|
98
|
+
DispatchQueue.main.async(execute: work)
|
|
99
|
+
} else {
|
|
100
|
+
work()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Public API
|
|
105
|
+
|
|
106
|
+
/// Add a single geofence. Replaces existing geofence with the same identifier.
|
|
107
|
+
/// Success/error callbacks are fired asynchronously after the OS confirms registration.
|
|
108
|
+
public func addGeofence(
|
|
109
|
+
_ geofence: GeofenceConfig, onSuccess: @escaping () -> Void,
|
|
110
|
+
onError: @escaping (String) -> Void
|
|
111
|
+
) {
|
|
112
|
+
var current = getGeofences()
|
|
113
|
+
|
|
114
|
+
// Replace existing with same identifier
|
|
115
|
+
current.removeAll { $0.identifier == geofence.identifier }
|
|
116
|
+
|
|
117
|
+
if current.count >= Self.maxGeofences {
|
|
118
|
+
onError("Cannot add geofence — limit of \(Self.maxGeofences) reached.")
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
current.append(geofence)
|
|
123
|
+
saveGeofences(current)
|
|
124
|
+
|
|
125
|
+
registerNativeGeofence(geofence, onSuccess: onSuccess, onError: onError)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Add multiple geofences atomically. If total would exceed limit, none are added.
|
|
129
|
+
/// The onSuccess callback fires only after all regions are confirmed by the OS.
|
|
130
|
+
public func addGeofences(
|
|
131
|
+
_ geofences: [GeofenceConfig], onSuccess: @escaping () -> Void,
|
|
132
|
+
onError: @escaping (String) -> Void
|
|
133
|
+
) {
|
|
134
|
+
var current = getGeofences()
|
|
135
|
+
|
|
136
|
+
let newIdentifiers = Set(geofences.map { $0.identifier })
|
|
137
|
+
current.removeAll { newIdentifiers.contains($0.identifier) }
|
|
138
|
+
|
|
139
|
+
if current.count + geofences.count > Self.maxGeofences {
|
|
140
|
+
onError("Cannot add geofences — would exceed limit of \(Self.maxGeofences).")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
current.append(contentsOf: geofences)
|
|
145
|
+
saveGeofences(current)
|
|
146
|
+
|
|
147
|
+
registerNativeGeofences(geofences, onSuccess: onSuccess, onError: onError)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Remove a geofence by identifier. No-op if not found.
|
|
151
|
+
public func removeGeofence(identifier: String) {
|
|
152
|
+
var current = getGeofences()
|
|
153
|
+
current.removeAll { $0.identifier == identifier }
|
|
154
|
+
saveGeofences(current)
|
|
155
|
+
cancelDwellTimer(for: identifier)
|
|
156
|
+
|
|
157
|
+
dispatchOnMain { [self] in
|
|
158
|
+
pendingRegistrations.removeValue(forKey: identifier)
|
|
159
|
+
for region in regionMonitor.monitoredRegions {
|
|
160
|
+
if region.identifier == identifier {
|
|
161
|
+
regionMonitor.stopMonitoring(for: region)
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
print("BGLocation: Geofence removed: \(identifier)")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Remove all registered geofences.
|
|
170
|
+
public func removeAllGeofences() {
|
|
171
|
+
let current = getGeofences()
|
|
172
|
+
saveGeofences([])
|
|
173
|
+
cancelAllDwellTimers()
|
|
174
|
+
|
|
175
|
+
dispatchOnMain { [self] in
|
|
176
|
+
pendingRegistrations.removeAll()
|
|
177
|
+
for region in regionMonitor.monitoredRegions {
|
|
178
|
+
if current.contains(where: { $0.identifier == region.identifier }) {
|
|
179
|
+
regionMonitor.stopMonitoring(for: region)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
print("BGLocation: All geofences removed (\(current.count))")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Get all registered geofences from the cache (backed by persistence).
|
|
187
|
+
public func getGeofences() -> [GeofenceConfig] {
|
|
188
|
+
if let cached = cachedGeofences { return cached }
|
|
189
|
+
let loaded = loadGeofences()
|
|
190
|
+
cachedGeofences = loaded
|
|
191
|
+
return loaded
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Re-register all persisted geofences with the system.
|
|
195
|
+
/// Called after configure() to restore geofences (e.g. after app relaunch).
|
|
196
|
+
/// Also checks for retroactive dwell events from timestamps saved before termination.
|
|
197
|
+
public func reRegisterAllGeofences() {
|
|
198
|
+
let geofences = getGeofences()
|
|
199
|
+
guard !geofences.isEmpty else { return }
|
|
200
|
+
|
|
201
|
+
for geofence in geofences {
|
|
202
|
+
registerNativeGeofence(geofence)
|
|
203
|
+
}
|
|
204
|
+
checkPendingDwells()
|
|
205
|
+
print("BGLocation: Re-registered \(geofences.count) geofences")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// MARK: - Native Region Registration
|
|
209
|
+
|
|
210
|
+
/// Register a single geofence with optional async callbacks.
|
|
211
|
+
/// When callbacks are provided, they are stored as pending and resolved
|
|
212
|
+
/// by `didStartMonitoringFor` / `monitoringDidFailFor` delegate callbacks.
|
|
213
|
+
///
|
|
214
|
+
/// All `pendingRegistrations` access and `regionMonitor` calls are dispatched
|
|
215
|
+
/// to the main thread to ensure CLLocationManager delegate delivery.
|
|
216
|
+
private func registerNativeGeofence(
|
|
217
|
+
_ geofence: GeofenceConfig,
|
|
218
|
+
onSuccess: (() -> Void)? = nil,
|
|
219
|
+
onError: ((String) -> Void)? = nil
|
|
220
|
+
) {
|
|
221
|
+
let center = CLLocationCoordinate2D(
|
|
222
|
+
latitude: geofence.latitude, longitude: geofence.longitude)
|
|
223
|
+
let region = CLCircularRegion(
|
|
224
|
+
center: center, radius: geofence.radius, identifier: geofence.identifier)
|
|
225
|
+
|
|
226
|
+
region.notifyOnEntry = geofence.notifyOnEntry
|
|
227
|
+
region.notifyOnExit = geofence.notifyOnExit
|
|
228
|
+
|
|
229
|
+
dispatchOnMain { [self] in
|
|
230
|
+
if let onSuccess = onSuccess, let onError = onError {
|
|
231
|
+
pendingRegistrations[geofence.identifier] = (onSuccess: onSuccess, onError: onError)
|
|
232
|
+
}
|
|
233
|
+
regionMonitor.startMonitoring(for: region)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Register multiple geofences. onSuccess fires after all succeed, onError on first failure.
|
|
238
|
+
/// On failure, all batch identifiers are rolled back from persistence and already-registered
|
|
239
|
+
/// regions are stopped — matching Android's atomic `GeofencingClient.addGeofences()` behavior.
|
|
240
|
+
///
|
|
241
|
+
/// Thread-safety note: All `pendingRegistrations` access and `regionMonitor` calls are
|
|
242
|
+
/// dispatched to the main thread via `dispatchOnMain`, so `remaining` / `failed` access
|
|
243
|
+
/// is race-free.
|
|
244
|
+
private func registerNativeGeofences(
|
|
245
|
+
_ geofences: [GeofenceConfig],
|
|
246
|
+
onSuccess: @escaping () -> Void,
|
|
247
|
+
onError: @escaping (String) -> Void
|
|
248
|
+
) {
|
|
249
|
+
guard !geofences.isEmpty else {
|
|
250
|
+
onSuccess()
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let batchIdentifiers = Set(geofences.map { $0.identifier })
|
|
255
|
+
var remaining = geofences.count
|
|
256
|
+
var failed = false
|
|
257
|
+
var succeededIdentifiers: [String] = []
|
|
258
|
+
|
|
259
|
+
for geofence in geofences {
|
|
260
|
+
// Skip remaining registrations if a previous one already failed
|
|
261
|
+
guard !failed else { break }
|
|
262
|
+
|
|
263
|
+
let wrappedError: (String) -> Void = { [weak self] errorMsg in
|
|
264
|
+
guard !failed else { return }
|
|
265
|
+
failed = true
|
|
266
|
+
|
|
267
|
+
// Roll back ALL batch identifiers from persistence
|
|
268
|
+
if let self = self {
|
|
269
|
+
var current = self.getGeofences()
|
|
270
|
+
current.removeAll { batchIdentifiers.contains($0.identifier) }
|
|
271
|
+
self.saveGeofences(current)
|
|
272
|
+
|
|
273
|
+
// Stop monitoring regions that already succeeded in this batch
|
|
274
|
+
for region in self.regionMonitor.monitoredRegions
|
|
275
|
+
where succeededIdentifiers.contains(region.identifier) {
|
|
276
|
+
self.regionMonitor.stopMonitoring(for: region)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
onError(errorMsg)
|
|
281
|
+
}
|
|
282
|
+
let wrappedSuccess: () -> Void = { [weak self] in
|
|
283
|
+
guard !failed else { return }
|
|
284
|
+
if let identifier = self?.findGeofence(identifier: geofence.identifier)?.identifier
|
|
285
|
+
{
|
|
286
|
+
succeededIdentifiers.append(identifier)
|
|
287
|
+
}
|
|
288
|
+
remaining -= 1
|
|
289
|
+
if remaining == 0 {
|
|
290
|
+
onSuccess()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
registerNativeGeofence(geofence, onSuccess: wrappedSuccess, onError: wrappedError)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// MARK: - CLLocationManagerDelegate — Region Monitoring
|
|
298
|
+
|
|
299
|
+
public func locationManager(
|
|
300
|
+
_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion
|
|
301
|
+
) {
|
|
302
|
+
if let pending = pendingRegistrations.removeValue(forKey: region.identifier) {
|
|
303
|
+
pending.onSuccess()
|
|
304
|
+
}
|
|
305
|
+
print("BGLocation: Geofence registered: \(region.identifier)")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
|
309
|
+
guard let circularRegion = region as? CLCircularRegion else { return }
|
|
310
|
+
let geofence = findGeofence(identifier: circularRegion.identifier)
|
|
311
|
+
emitEvent(identifier: circularRegion.identifier, action: "enter", extras: geofence?.extras)
|
|
312
|
+
|
|
313
|
+
// Start dwell timer if configured
|
|
314
|
+
if let geofence = geofence, geofence.notifyOnDwell {
|
|
315
|
+
startDwellTimer(for: geofence)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
|
|
320
|
+
guard let circularRegion = region as? CLCircularRegion else { return }
|
|
321
|
+
let geofence = findGeofence(identifier: circularRegion.identifier)
|
|
322
|
+
|
|
323
|
+
// Cancel dwell timer — user left the region before dwell delay elapsed
|
|
324
|
+
cancelDwellTimer(for: circularRegion.identifier)
|
|
325
|
+
|
|
326
|
+
emitEvent(identifier: circularRegion.identifier, action: "exit", extras: geofence?.extras)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
public func locationManager(
|
|
330
|
+
_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error
|
|
331
|
+
) {
|
|
332
|
+
let identifier = region?.identifier ?? "unknown"
|
|
333
|
+
print(
|
|
334
|
+
"BGLocation: Geofence monitoring failed for \(identifier): \(error.localizedDescription)"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
// Reject pending registration callback and roll back persistence
|
|
338
|
+
if let pending = pendingRegistrations.removeValue(forKey: identifier) {
|
|
339
|
+
var current = getGeofences()
|
|
340
|
+
current.removeAll { $0.identifier == identifier }
|
|
341
|
+
saveGeofences(current)
|
|
342
|
+
pending.onError(
|
|
343
|
+
"Geofence monitoring failed for \(identifier): \(error.localizedDescription)")
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func emitEvent(identifier: String, action: String, extras: [String: String]?) {
|
|
348
|
+
let location: BGLLocationData?
|
|
349
|
+
if let clLocation = locationProvider?.lastKnownLocation {
|
|
350
|
+
location = BGLLocationHelpers.mapLocation(clLocation, isMoving: false)
|
|
351
|
+
} else {
|
|
352
|
+
location = nil
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let event = GeofenceEventData(
|
|
356
|
+
identifier: identifier,
|
|
357
|
+
action: action,
|
|
358
|
+
location: location,
|
|
359
|
+
extras: extras,
|
|
360
|
+
timestamp: Date().timeIntervalSince1970 * 1000
|
|
361
|
+
)
|
|
362
|
+
debug?.logGeofenceEvent(identifier: identifier, action: action)
|
|
363
|
+
onGeofenceEvent?(event)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// MARK: - Dwell Timer (iOS-specific — no native dwell support in CLLocationManager)
|
|
367
|
+
|
|
368
|
+
/// Start a DispatchSourceTimer that fires a "dwell" event after `dwellDelay` seconds.
|
|
369
|
+
/// Also persists the enter timestamp in UserDefaults for retroactive dwell on app wake.
|
|
370
|
+
private func startDwellTimer(for geofence: GeofenceConfig) {
|
|
371
|
+
cancelDwellTimer(for: geofence.identifier)
|
|
372
|
+
|
|
373
|
+
// Persist enter timestamp for retroactive dwell after app termination
|
|
374
|
+
let key = Self.dwellTimestampPrefix + geofence.identifier
|
|
375
|
+
defaults.set(Date().timeIntervalSince1970, forKey: key)
|
|
376
|
+
|
|
377
|
+
let timer = DispatchSource.makeTimerSource(queue: .main)
|
|
378
|
+
timer.schedule(deadline: .now() + .seconds(geofence.dwellDelay))
|
|
379
|
+
timer.setEventHandler { [weak self] in
|
|
380
|
+
self?.dwellTimers.removeValue(forKey: geofence.identifier)
|
|
381
|
+
self?.defaults.removeObject(forKey: key)
|
|
382
|
+
self?.emitEvent(
|
|
383
|
+
identifier: geofence.identifier, action: "dwell", extras: geofence.extras)
|
|
384
|
+
}
|
|
385
|
+
dwellTimers[geofence.identifier] = timer
|
|
386
|
+
timer.resume()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Cancel a dwell timer and remove the persisted enter timestamp.
|
|
390
|
+
private func cancelDwellTimer(for identifier: String) {
|
|
391
|
+
if let timer = dwellTimers.removeValue(forKey: identifier) {
|
|
392
|
+
timer.cancel()
|
|
393
|
+
}
|
|
394
|
+
defaults.removeObject(forKey: Self.dwellTimestampPrefix + identifier)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Cancel all active dwell timers and remove all persisted enter timestamps.
|
|
398
|
+
private func cancelAllDwellTimers() {
|
|
399
|
+
for (identifier, timer) in dwellTimers {
|
|
400
|
+
timer.cancel()
|
|
401
|
+
defaults.removeObject(forKey: Self.dwellTimestampPrefix + identifier)
|
|
402
|
+
}
|
|
403
|
+
dwellTimers.removeAll()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// Check persisted dwell enter timestamps and emit retroactive dwell events
|
|
407
|
+
/// for regions where the user has been inside longer than `dwellDelay`.
|
|
408
|
+
/// Called on app wake / reRegisterAllGeofences() — handles the case where
|
|
409
|
+
/// DispatchSourceTimer didn't survive app termination.
|
|
410
|
+
private func checkPendingDwells() {
|
|
411
|
+
let now = Date().timeIntervalSince1970
|
|
412
|
+
for geofence in getGeofences() where geofence.notifyOnDwell {
|
|
413
|
+
let key = Self.dwellTimestampPrefix + geofence.identifier
|
|
414
|
+
let enterTimestamp = defaults.double(forKey: key)
|
|
415
|
+
guard enterTimestamp > 0 else { continue }
|
|
416
|
+
|
|
417
|
+
let elapsed = now - enterTimestamp
|
|
418
|
+
if elapsed >= Double(geofence.dwellDelay) {
|
|
419
|
+
// Dwell time has elapsed while app was terminated — emit retroactive event
|
|
420
|
+
defaults.removeObject(forKey: key)
|
|
421
|
+
emitEvent(identifier: geofence.identifier, action: "dwell", extras: geofence.extras)
|
|
422
|
+
print(
|
|
423
|
+
"BGLocation: Retroactive dwell event for \(geofence.identifier) (elapsed: \(Int(elapsed))s)"
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
// If elapsed < dwellDelay, the timer will be (re-)started when the next
|
|
427
|
+
// region event arrives. We don't restart timers here because we can't know
|
|
428
|
+
// for certain the user is still inside the region after termination.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// MARK: - Persistence (UserDefaults JSON)
|
|
433
|
+
|
|
434
|
+
private func loadGeofences() -> [GeofenceConfig] {
|
|
435
|
+
guard let data = defaults.data(forKey: Self.defaultsKey) else { return [] }
|
|
436
|
+
do {
|
|
437
|
+
return try JSONDecoder().decode([GeofenceConfig].self, from: data)
|
|
438
|
+
} catch {
|
|
439
|
+
print("BGLocation: Failed to decode geofences: \(error.localizedDescription)")
|
|
440
|
+
return []
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private func saveGeofences(_ geofences: [GeofenceConfig]) {
|
|
445
|
+
cachedGeofences = geofences
|
|
446
|
+
do {
|
|
447
|
+
let data = try JSONEncoder().encode(geofences)
|
|
448
|
+
defaults.set(data, forKey: Self.defaultsKey)
|
|
449
|
+
} catch {
|
|
450
|
+
print("BGLocation: Failed to encode geofences: \(error.localizedDescription)")
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private func findGeofence(identifier: String) -> GeofenceConfig? {
|
|
455
|
+
getGeofences().first { $0.identifier == identifier }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// MARK: - Data Models
|
|
460
|
+
|
|
461
|
+
/// Geofence configuration stored in persistence.
|
|
462
|
+
public struct GeofenceConfig: Codable, Equatable {
|
|
463
|
+
public let identifier: String
|
|
464
|
+
public let latitude: Double
|
|
465
|
+
public let longitude: Double
|
|
466
|
+
public let radius: Double
|
|
467
|
+
public let notifyOnEntry: Bool
|
|
468
|
+
public let notifyOnExit: Bool
|
|
469
|
+
public let notifyOnDwell: Bool
|
|
470
|
+
public let dwellDelay: Int
|
|
471
|
+
public let extras: [String: String]?
|
|
472
|
+
|
|
473
|
+
public init(
|
|
474
|
+
identifier: String,
|
|
475
|
+
latitude: Double,
|
|
476
|
+
longitude: Double,
|
|
477
|
+
radius: Double,
|
|
478
|
+
notifyOnEntry: Bool = true,
|
|
479
|
+
notifyOnExit: Bool = true,
|
|
480
|
+
notifyOnDwell: Bool = false,
|
|
481
|
+
dwellDelay: Int = 300,
|
|
482
|
+
extras: [String: String]? = nil
|
|
483
|
+
) {
|
|
484
|
+
self.identifier = identifier
|
|
485
|
+
self.latitude = latitude
|
|
486
|
+
self.longitude = longitude
|
|
487
|
+
self.radius = radius
|
|
488
|
+
self.notifyOnEntry = notifyOnEntry
|
|
489
|
+
self.notifyOnExit = notifyOnExit
|
|
490
|
+
self.notifyOnDwell = notifyOnDwell
|
|
491
|
+
self.dwellDelay = dwellDelay
|
|
492
|
+
self.extras = extras
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/// Convert to a dictionary suitable for the Capacitor JS bridge.
|
|
496
|
+
public func toDict() -> [String: Any] {
|
|
497
|
+
var dict: [String: Any] = [
|
|
498
|
+
"identifier": identifier,
|
|
499
|
+
"latitude": latitude,
|
|
500
|
+
"longitude": longitude,
|
|
501
|
+
"radius": radius,
|
|
502
|
+
"notifyOnEntry": notifyOnEntry,
|
|
503
|
+
"notifyOnExit": notifyOnExit,
|
|
504
|
+
"notifyOnDwell": notifyOnDwell,
|
|
505
|
+
"dwellDelay": dwellDelay,
|
|
506
|
+
]
|
|
507
|
+
if let extras = extras {
|
|
508
|
+
dict["extras"] = extras
|
|
509
|
+
}
|
|
510
|
+
return dict
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/// Geofence event emitted to JS layer.
|
|
515
|
+
public struct GeofenceEventData {
|
|
516
|
+
public let identifier: String
|
|
517
|
+
public let action: String // "enter", "exit", "dwell"
|
|
518
|
+
public let location: BGLLocationData?
|
|
519
|
+
public let extras: [String: String]?
|
|
520
|
+
public let timestamp: Double
|
|
521
|
+
|
|
522
|
+
public func toDict() -> [String: Any] {
|
|
523
|
+
var dict: [String: Any] = [
|
|
524
|
+
"identifier": identifier,
|
|
525
|
+
"action": action,
|
|
526
|
+
"timestamp": timestamp,
|
|
527
|
+
]
|
|
528
|
+
if let location = location {
|
|
529
|
+
dict["location"] = location.toDict()
|
|
530
|
+
} else {
|
|
531
|
+
dict["location"] = NSNull()
|
|
532
|
+
}
|
|
533
|
+
if let extras = extras {
|
|
534
|
+
dict["extras"] = extras
|
|
535
|
+
}
|
|
536
|
+
return dict
|
|
537
|
+
}
|
|
538
|
+
}
|