@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.
Files changed (61) hide show
  1. package/CapacitorBackgroundLocation.podspec +19 -0
  2. package/LICENSE.md +97 -0
  3. package/Package.swift +44 -0
  4. package/README.md +264 -0
  5. package/android/build.gradle +74 -0
  6. package/android/src/main/AndroidManifest.xml +37 -0
  7. package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
  8. package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
  9. package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
  10. package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
  11. package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
  12. package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
  13. package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
  14. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
  15. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
  16. package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
  17. package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
  18. package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
  19. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
  20. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
  21. package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
  22. package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
  23. package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
  24. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
  25. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
  26. package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
  27. package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
  28. package/dist/esm/definitions.d.ts +390 -0
  29. package/dist/esm/definitions.js +3 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +4 -0
  32. package/dist/esm/index.js +26 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/web.d.ts +47 -0
  35. package/dist/esm/web.js +231 -0
  36. package/dist/esm/web.js.map +1 -0
  37. package/dist/esm/web.test.d.ts +1 -0
  38. package/dist/esm/web.test.js +940 -0
  39. package/dist/esm/web.test.js.map +1 -0
  40. package/dist/plugin.cjs.js +267 -0
  41. package/dist/plugin.cjs.js.map +1 -0
  42. package/dist/plugin.js +270 -0
  43. package/dist/plugin.js.map +1 -0
  44. package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
  45. package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
  46. package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
  47. package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
  48. package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
  49. package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
  50. package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
  51. package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
  52. package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
  53. package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
  54. package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
  55. package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
  56. package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
  57. package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
  58. package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
  59. package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
  60. package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
  61. 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
+ }