@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,657 @@
1
+ import BGLocationCore
2
+ import Capacitor
3
+ import CoreLocation
4
+ import Foundation
5
+
6
+ /// Capacitor plugin bridging JS ↔ iOS native background location tracking.
7
+ @objc(BackgroundLocationPlugin)
8
+ public class BackgroundLocationPlugin: CAPPlugin, CAPBridgedPlugin {
9
+
10
+ public let identifier = "BackgroundLocationPlugin"
11
+ public let jsName = "BackgroundLocation"
12
+
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ .init(name: "getVersion", returnType: CAPPluginReturnPromise),
15
+ .init(name: "checkPermissions", returnType: CAPPluginReturnPromise),
16
+ .init(name: "requestPermissions", returnType: CAPPluginReturnPromise),
17
+ .init(name: "configure", returnType: CAPPluginReturnPromise),
18
+ .init(name: "start", returnType: CAPPluginReturnPromise),
19
+ .init(name: "stop", returnType: CAPPluginReturnPromise),
20
+ .init(name: "getState", returnType: CAPPluginReturnPromise),
21
+ .init(name: "getCurrentPosition", returnType: CAPPluginReturnPromise),
22
+ .init(name: "checkBatteryOptimization", returnType: CAPPluginReturnPromise),
23
+ .init(name: "requestBatteryOptimization", returnType: CAPPluginReturnPromise),
24
+ .init(name: "addGeofence", returnType: CAPPluginReturnPromise),
25
+ .init(name: "addGeofences", returnType: CAPPluginReturnPromise),
26
+ .init(name: "removeGeofence", returnType: CAPPluginReturnPromise),
27
+ .init(name: "removeAllGeofences", returnType: CAPPluginReturnPromise),
28
+ .init(name: "getGeofences", returnType: CAPPluginReturnPromise),
29
+ ]
30
+
31
+ private let locationManager = BGLLocationManager()
32
+ internal var heartbeatTimer: BGLHeartbeatTimer?
33
+ private var heartbeatInterval: Int = 15
34
+ private var pendingPermissionCall: CAPPluginCall?
35
+ private var isConfigured = false
36
+ private lazy var geofenceManager = BGLGeofenceManager()
37
+ /// Timeout for iOS permission resolution fallback.
38
+ /// iOS 13.4+ may grant provisional "Always" without triggering a delegate callback.
39
+ private static let permissionTimeoutSeconds: TimeInterval = 3.0
40
+
41
+ internal var httpSender: BGLHttpSender = BGLHttpSender()
42
+ internal var locationBuffer: BGLLocationBuffer?
43
+ internal var debug: BGLDebugLogger = BGLDebugLogger()
44
+
45
+ // Adaptive distance filter
46
+ internal var adaptiveFilter: BGLAdaptiveFilter?
47
+ private var distanceFilterMode: String = "fixed"
48
+
49
+ // Licensing
50
+ internal lazy var licenseEnforcer = BGLLicenseEnforcer(
51
+ defaults: UserDefaults(suiteName: "dev.bglocation.license")!
52
+ )
53
+ internal var licenseMode: LicenseMode { licenseEnforcer.licenseMode }
54
+ internal var trialTimer: BGLTrialTimer? { licenseEnforcer.trialTimer }
55
+
56
+ /// Build epoch for update gating. Updated by pre-build script before release.
57
+ /// GENERATED — do not edit manually
58
+ private static let pluginBuildEpoch: Int = 1774777856
59
+
60
+ // MARK: - Version
61
+
62
+ @objc func getVersion(_ call: CAPPluginCall) {
63
+ call.resolve([
64
+ "pluginVersion": "1.1.0",
65
+ "coreVersion": BGLVersion.version,
66
+ ])
67
+ }
68
+
69
+ // MARK: - Permission Methods
70
+
71
+ @objc public override func checkPermissions(_ call: CAPPluginCall) {
72
+ let status = locationManager.authorizationStatus
73
+ call.resolve(BGLPermissionManager.permissionStatusDict(status))
74
+ }
75
+
76
+ @objc public override func requestPermissions(_ call: CAPPluginCall) {
77
+ let permissions = call.getArray("permissions", String.self) ?? ["location"]
78
+ let requestBackground = permissions.contains("backgroundLocation")
79
+ let status = locationManager.authorizationStatus
80
+
81
+ if requestBackground {
82
+ // Step 2: Escalate from "When In Use" → "Always"
83
+ if status == .authorizedWhenInUse {
84
+ pendingPermissionCall = call
85
+ locationManager.onAuthorizationResolved = { [weak self] newStatus in
86
+ guard let self = self, let pending = self.pendingPermissionCall else { return }
87
+ pending.resolve(BGLPermissionManager.permissionStatusDict(newStatus))
88
+ self.pendingPermissionCall = nil
89
+ }
90
+ locationManager.requestAlwaysAuthorization()
91
+
92
+ // Fallback: resolve after timeout for iOS 13.4+ provisional grant.
93
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.permissionTimeoutSeconds) {
94
+ [weak self] in
95
+ guard let self = self, let pending = self.pendingPermissionCall else { return }
96
+ let currentStatus = self.locationManager.authorizationStatus
97
+ pending.resolve(BGLPermissionManager.permissionStatusDict(currentStatus))
98
+ self.pendingPermissionCall = nil
99
+ self.locationManager.onAuthorizationResolved = nil
100
+ }
101
+ } else {
102
+ // Already "Always", denied, or not determined — return current status
103
+ checkPermissions(call)
104
+ }
105
+ } else {
106
+ // Step 1: Request "When In Use"
107
+ if status == .notDetermined {
108
+ pendingPermissionCall = call
109
+ locationManager.onAuthorizationResolved = { [weak self] newStatus in
110
+ guard let self = self, let pending = self.pendingPermissionCall else { return }
111
+ pending.resolve(BGLPermissionManager.permissionStatusDict(newStatus))
112
+ self.pendingPermissionCall = nil
113
+ }
114
+ locationManager.requestWhenInUseAuthorization()
115
+ } else {
116
+ checkPermissions(call)
117
+ }
118
+ }
119
+ }
120
+
121
+ // MARK: - Plugin Methods
122
+
123
+ @objc func configure(_ call: CAPPluginCall) {
124
+ BGLBuildConfig.buildEpoch = Self.pluginBuildEpoch
125
+ let parsed = BGLConfigParser.parse(call.options as? [String: Any] ?? [:])
126
+ heartbeatInterval = parsed.heartbeatInterval
127
+ distanceFilterMode = parsed.distanceFilterMode
128
+
129
+ // Adaptive distance filter setup
130
+ if let autoConfig = parsed.autoDistanceFilterConfig {
131
+ adaptiveFilter = BGLAdaptiveFilter(config: autoConfig)
132
+ } else {
133
+ adaptiveFilter = nil
134
+ }
135
+
136
+ // Debug config
137
+ debug.configure(debug: parsed.isDebug, debugSounds: parsed.debugSounds)
138
+ debug.onDebug = { [weak self] message in
139
+ self?.notifyListeners(
140
+ "onDebug",
141
+ data: [
142
+ "message": message,
143
+ "timestamp": Date().timeIntervalSince1970 * 1000,
144
+ ])
145
+ }
146
+
147
+ // HTTP config
148
+ if let url = parsed.httpUrl {
149
+ httpSender.configure(url: url, headers: parsed.httpHeaders)
150
+ httpSender.onFlushProgress = { [weak self] result in
151
+ DispatchQueue.main.async {
152
+ self?.emitHttp(result)
153
+ }
154
+ }
155
+ if let maxSize = parsed.bufferMaxSize {
156
+ locationBuffer?.close()
157
+ locationBuffer = BGLLocationBuffer(maxSize: maxSize)
158
+ locationBuffer?.onOverflow = { [weak self] dropped in
159
+ self?.debug.logMessage("BUFFER_OVERFLOW dropped=\(dropped) maxSize=\(maxSize)")
160
+ }
161
+ httpSender.setBuffer(locationBuffer)
162
+ print("BGLocation: Offline buffer configured — maxSize=\(maxSize)")
163
+ } else {
164
+ locationBuffer = nil
165
+ httpSender.setBuffer(nil)
166
+ }
167
+ } else {
168
+ httpSender.configure(url: nil, headers: [:])
169
+ locationBuffer = nil
170
+ httpSender.setBuffer(nil)
171
+ }
172
+
173
+ locationManager.configure(
174
+ distanceFilter: parsed.distanceFilter,
175
+ desiredAccuracy: BGLLocationHelpers.mapAccuracy(parsed.desiredAccuracy)
176
+ )
177
+
178
+ debug.logConfigure(
179
+ distanceFilter: parsed.distanceFilter, heartbeat: heartbeatInterval,
180
+ httpUrl: parsed.httpUrl)
181
+ print(
182
+ "BGLocation: BackgroundLocation configured — distanceFilter=\(parsed.distanceFilter), heartbeat=\(heartbeatInterval)s, http=\(parsed.httpUrl ?? "disabled")"
183
+ )
184
+
185
+ // --- License validation ---
186
+ let licenseKey = getConfig().getString("licenseKey")
187
+ var configureResult: [String: Any] = [:]
188
+ configureResult["distanceFilterMode"] = distanceFilterMode
189
+
190
+ licenseEnforcer.validateLicense(
191
+ licenseKey: licenseKey,
192
+ isDebug: parsed.isDebug,
193
+ debugSounds: parsed.debugSounds,
194
+ debug: debug,
195
+ configureResult: &configureResult,
196
+ onTrialExpired: { [weak self] in self?.onTrialExpired() }
197
+ )
198
+
199
+ call.resolve(configureResult)
200
+
201
+ isConfigured = true
202
+
203
+ // Set up geofence event callback and re-register persisted geofences
204
+ geofenceManager.locationProvider = locationManager
205
+ geofenceManager.debug = debug
206
+ geofenceManager.onGeofenceEvent = { [weak self] event in
207
+ self?.notifyListeners("onGeofence", data: event.toDict())
208
+ }
209
+ geofenceManager.reRegisterAllGeofences()
210
+ }
211
+
212
+ @objc func start(_ call: CAPPluginCall) {
213
+ let status = locationManager.authorizationStatus
214
+ if status == .denied || status == .restricted {
215
+ call.reject("Location permission not granted")
216
+ return
217
+ }
218
+
219
+ // Trial mode: check cooldown before allowing start
220
+ if let cooldown = licenseEnforcer.checkCooldown() {
221
+ call.reject(
222
+ cooldown.message,
223
+ "TRIAL_COOLDOWN",
224
+ nil,
225
+ ["remainingSeconds": cooldown.remainingSeconds, "message": cooldown.message]
226
+ )
227
+ return
228
+ }
229
+
230
+ locationManager.onLocation = { [weak self] location, isMoving in
231
+ self?.emitLocation(location, isMoving: isMoving)
232
+ }
233
+
234
+ locationManager.onProviderChange = { [weak self] status in
235
+ self?.emitProviderChange(status)
236
+ }
237
+
238
+ locationManager.onAccuracyChange = { [weak self] accuracy in
239
+ DispatchQueue.main.async {
240
+ self?.emitAccuracyWarning(authorization: accuracy)
241
+ }
242
+ }
243
+
244
+ locationManager.onSignificantLocationChange = { [weak self] in
245
+ DispatchQueue.main.async {
246
+ self?.debug.logMessage(
247
+ "SLC_RESTART — Significant Location Change watchdog restarted standard updates")
248
+ }
249
+ }
250
+
251
+ locationManager.startTracking()
252
+ startHeartbeat()
253
+
254
+ // Start trial timer if in trial mode
255
+ licenseEnforcer.startTrialIfNeeded()
256
+
257
+ // B.2: Check accuracy authorization and emit warning if approximate
258
+ checkAndEmitAccuracyWarning()
259
+
260
+ debug.logStart()
261
+ print("BGLocation: BackgroundLocation started")
262
+ call.resolve(["enabled": true, "tracking": true])
263
+ }
264
+
265
+ /// Internal stop — tears down tracking, clears callbacks, stops heartbeat.
266
+ /// Shared by stop() and onTrialExpired() to avoid duplicating cleanup logic.
267
+ private func performStopTracking() {
268
+ locationManager.stopTracking()
269
+ locationManager.onAccuracyChange = nil
270
+ locationManager.onSignificantLocationChange = nil
271
+ stopHeartbeat()
272
+ }
273
+
274
+ @objc func stop(_ call: CAPPluginCall) {
275
+ // Only stop the trial timer if no geofences are active
276
+ licenseEnforcer.stopTrialIfNoGeofences(hasGeofences: geofenceManager.hasGeofences)
277
+ performStopTracking()
278
+ adaptiveFilter?.reset()
279
+
280
+ debug.logStop()
281
+ print("BGLocation: BackgroundLocation stopped")
282
+ call.resolve(["enabled": locationManager.isAuthorized, "tracking": false])
283
+ }
284
+
285
+ @objc func getState(_ call: CAPPluginCall) {
286
+ call.resolve([
287
+ "enabled": locationManager.isAuthorized,
288
+ "tracking": locationManager.isTracking,
289
+ ])
290
+ }
291
+
292
+ @objc func getCurrentPosition(_ call: CAPPluginCall) {
293
+ let timeout = call.getDouble("timeout") ?? 20000
294
+
295
+ locationManager.requestCurrentPosition(timeout: timeout / 1000.0) { location in
296
+ if let location = location {
297
+ call.resolve(BGLLocationHelpers.locationToDict(location, isMoving: false))
298
+ } else {
299
+ call.reject(
300
+ "Unable to get current position \u{2014} no location available and request timed out"
301
+ )
302
+ }
303
+ }
304
+ }
305
+
306
+ // MARK: - Battery Optimization (B.1 — no-op on iOS)
307
+
308
+ @objc func checkBatteryOptimization(_ call: CAPPluginCall) {
309
+ // iOS does not have OEM battery optimization like Android.
310
+ // Always return "safe" state.
311
+ call.resolve([
312
+ "isIgnoringOptimizations": true,
313
+ "manufacturer": "",
314
+ "helpUrl": "",
315
+ "message": "Battery optimization is not applicable on iOS.",
316
+ ])
317
+ }
318
+
319
+ @objc func requestBatteryOptimization(_ call: CAPPluginCall) {
320
+ checkBatteryOptimization(call)
321
+ }
322
+
323
+ // MARK: - Accuracy Warning (B.2)
324
+
325
+ /// Check if the user has granted only approximate location, and emit onAccuracyWarning if so.
326
+ /// If approximate, also attempts to request temporary full accuracy.
327
+ private func checkAndEmitAccuracyWarning() {
328
+ guard !locationManager.isFullAccuracy else { return }
329
+
330
+ // Emit immediate warning — the user currently has approximate location
331
+ emitAccuracyWarning(authorization: .reducedAccuracy)
332
+
333
+ // Attempt to request temporary full accuracy.
334
+ // NOTE: If the user grants it, locationManagerDidChangeAuthorization fires
335
+ // → onAccuracyChange callback → emitAccuracyWarning. We do NOT emit here
336
+ // to avoid duplicate events.
337
+ locationManager.requestTemporaryFullAccuracy(purposeKey: "PreciseLocation") { result in
338
+ DispatchQueue.main.async {
339
+ if result == .reducedAccuracy {
340
+ print(
341
+ "BGLocation: User declined temporary full accuracy — tracking with approximate location"
342
+ )
343
+ } else {
344
+ print("BGLocation: Temporary full accuracy granted")
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ private func emitAccuracyWarning(authorization: CLAccuracyAuthorization) {
351
+ let authString = authorization == .fullAccuracy ? "full" : "approximate"
352
+ let message =
353
+ authorization == .fullAccuracy
354
+ ? "Full accuracy location is available."
355
+ : "Approximate location is active. GPS tracking accuracy is significantly degraded. Please grant precise location in Settings."
356
+
357
+ notifyListeners(
358
+ "onAccuracyWarning",
359
+ data: [
360
+ "accuracyAuthorization": authString,
361
+ "message": message,
362
+ ])
363
+ }
364
+
365
+ // MARK: - Heartbeat
366
+
367
+ private func startHeartbeat() {
368
+ stopHeartbeat()
369
+ heartbeatTimer = BGLHeartbeatTimer(intervalSeconds: heartbeatInterval) { [weak self] in
370
+ // Dispatch to main queue — BGLHeartbeatTimer fires on a .utility background queue
371
+ // but Capacitor's notifyListeners and debug callbacks must run on main.
372
+ DispatchQueue.main.async {
373
+ guard let self = self else { return }
374
+ let now = Date().timeIntervalSince1970 * 1000
375
+ let hasLocation = self.locationManager.lastKnownLocation != nil
376
+ if let location = self.locationManager.lastKnownLocation {
377
+ self.notifyListeners(
378
+ "onHeartbeat",
379
+ data: [
380
+ "location": BGLLocationHelpers.mapLocation(location, isMoving: false)
381
+ .toDict(),
382
+ "timestamp": now,
383
+ ])
384
+ } else {
385
+ self.notifyListeners(
386
+ "onHeartbeat",
387
+ data: [
388
+ "location": NSNull(),
389
+ "timestamp": now,
390
+ ])
391
+ }
392
+ self.debug.logHeartbeat(hasLocation: hasLocation)
393
+ }
394
+ }
395
+ heartbeatTimer?.start()
396
+ }
397
+
398
+ private func stopHeartbeat() {
399
+ heartbeatTimer?.stop()
400
+ heartbeatTimer = nil
401
+ }
402
+
403
+ // MARK: - Event Emission
404
+
405
+ private func emitLocation(_ location: CLLocation, isMoving: Bool) {
406
+ // Adaptive distance filter — update and reconfigure if needed
407
+ if let filter = adaptiveFilter {
408
+ let speed = max(location.speed, 0)
409
+ let shouldReconfigure = filter.update(speedMps: speed)
410
+ if shouldReconfigure {
411
+ let newDistance = filter.effectiveDistanceFilter
412
+ locationManager.updateDistanceFilter(newDistance)
413
+ debug.logMessage(
414
+ "Auto distance filter: \(Int(newDistance))m (speed: \(String(format: "%.1f", speed))m/s)"
415
+ )
416
+ }
417
+ }
418
+
419
+ let locationData = BGLLocationHelpers.mapLocation(location, isMoving: isMoving)
420
+ var dict = locationData.toDict()
421
+ if let filter = adaptiveFilter {
422
+ dict["effectiveDistanceFilter"] = filter.effectiveDistanceFilter
423
+ }
424
+ notifyListeners("onLocation", data: dict)
425
+
426
+ debug.logLocation(
427
+ lat: location.coordinate.latitude,
428
+ lng: location.coordinate.longitude,
429
+ accuracy: location.horizontalAccuracy,
430
+ speed: max(location.speed, 0),
431
+ isMoving: isMoving
432
+ )
433
+
434
+ httpSender.sendLocation(locationData) { [weak self] result in
435
+ DispatchQueue.main.async {
436
+ self?.debug.logHttp(
437
+ statusCode: result.statusCode, success: result.success, error: result.error)
438
+ self?.emitHttp(result)
439
+ }
440
+ }
441
+ }
442
+
443
+ private func emitProviderChange(_ status: CLAuthorizationStatus) {
444
+ let enabled: Bool
445
+ switch status {
446
+ case .authorizedAlways, .authorizedWhenInUse:
447
+ enabled = true
448
+ default:
449
+ enabled = false
450
+ }
451
+
452
+ notifyListeners(
453
+ "onProviderChange",
454
+ data: [
455
+ "gps": enabled,
456
+ "network": enabled,
457
+ "enabled": enabled,
458
+ "status": status.rawValue,
459
+ ])
460
+ }
461
+
462
+ private func emitHttp(_ result: BGLHttpResult) {
463
+ var data: [String: Any] = [
464
+ "statusCode": result.statusCode,
465
+ "success": result.success,
466
+ "responseText": result.responseText,
467
+ "bufferedCount": result.bufferedCount,
468
+ ]
469
+ if let error = result.error {
470
+ data["error"] = error
471
+ }
472
+ notifyListeners("onHttp", data: data)
473
+ }
474
+
475
+ // MARK: - Geofencing Methods
476
+
477
+ /// Common guard for all geofence methods: checks isConfigured and trial cooldown.
478
+ /// Returns `true` if the call should proceed, or rejects and returns `false`.
479
+ private func guardGeofenceCall(_ call: CAPPluginCall, checkCooldown: Bool = false) -> Bool {
480
+ guard isConfigured else {
481
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
482
+ return false
483
+ }
484
+ if checkCooldown, let cooldown = licenseEnforcer.checkCooldown() {
485
+ call.reject(
486
+ cooldown.message,
487
+ "TRIAL_COOLDOWN",
488
+ nil,
489
+ ["remainingSeconds": cooldown.remainingSeconds]
490
+ )
491
+ return false
492
+ }
493
+ return true
494
+ }
495
+
496
+ @objc func addGeofence(_ call: CAPPluginCall) {
497
+ guard guardGeofenceCall(call, checkCooldown: true) else { return }
498
+
499
+ guard let identifier = call.getString("identifier") else {
500
+ call.reject("Missing required parameter: identifier")
501
+ return
502
+ }
503
+ guard let latitude = call.getDouble("latitude"),
504
+ let longitude = call.getDouble("longitude"),
505
+ let radius = call.getDouble("radius")
506
+ else {
507
+ call.reject("Missing required parameters: latitude, longitude, radius")
508
+ return
509
+ }
510
+
511
+ var extras: [String: String]?
512
+ if let extrasObj = call.getObject("extras") {
513
+ extras = extrasObj.compactMapValues { $0 as? String }
514
+ }
515
+
516
+ let geofence = GeofenceConfig(
517
+ identifier: identifier,
518
+ latitude: latitude,
519
+ longitude: longitude,
520
+ radius: radius,
521
+ notifyOnEntry: call.getBool("notifyOnEntry") ?? true,
522
+ notifyOnExit: call.getBool("notifyOnExit") ?? true,
523
+ notifyOnDwell: call.getBool("notifyOnDwell") ?? false,
524
+ dwellDelay: call.getInt("dwellDelay") ?? 300,
525
+ extras: extras
526
+ )
527
+
528
+ geofenceManager.addGeofence(
529
+ geofence,
530
+ onSuccess: { [weak self] in
531
+ self?.licenseEnforcer.startTrialIfNeeded()
532
+ self?.debug.logGeofenceAdd(identifier: identifier)
533
+ call.resolve()
534
+ },
535
+ onError: { errorMsg in
536
+ let code = errorMsg.contains("limit") ? "GEOFENCE_LIMIT_EXCEEDED" : "GEOFENCE_ERROR"
537
+ call.reject(errorMsg, code)
538
+ }
539
+ )
540
+ }
541
+
542
+ @objc func addGeofences(_ call: CAPPluginCall) {
543
+ guard guardGeofenceCall(call, checkCooldown: true) else { return }
544
+
545
+ guard let geofencesArray = call.getArray("geofences") as? [[String: Any]] else {
546
+ call.reject("Missing required parameter: geofences")
547
+ return
548
+ }
549
+
550
+ var geofences: [GeofenceConfig] = []
551
+ for (index, obj) in geofencesArray.enumerated() {
552
+ guard let identifier = obj["identifier"] as? String,
553
+ let latitude = obj["latitude"] as? Double,
554
+ let longitude = obj["longitude"] as? Double,
555
+ let radius = obj["radius"] as? Double
556
+ else {
557
+ call.reject(
558
+ "Geofence at index \(index) missing required fields (identifier, latitude, longitude, radius)"
559
+ )
560
+ return
561
+ }
562
+
563
+ let extras = obj["extras"] as? [String: String]
564
+
565
+ geofences.append(
566
+ GeofenceConfig(
567
+ identifier: identifier,
568
+ latitude: latitude,
569
+ longitude: longitude,
570
+ radius: radius,
571
+ notifyOnEntry: obj["notifyOnEntry"] as? Bool ?? true,
572
+ notifyOnExit: obj["notifyOnExit"] as? Bool ?? true,
573
+ notifyOnDwell: obj["notifyOnDwell"] as? Bool ?? false,
574
+ dwellDelay: obj["dwellDelay"] as? Int ?? 300,
575
+ extras: extras
576
+ ))
577
+ }
578
+
579
+ geofenceManager.addGeofences(
580
+ geofences,
581
+ onSuccess: { [weak self] in
582
+ self?.licenseEnforcer.startTrialIfNeeded()
583
+ self?.debug.logGeofenceAddBatch(count: geofences.count)
584
+ call.resolve()
585
+ },
586
+ onError: { errorMsg in
587
+ let code = errorMsg.contains("limit") ? "GEOFENCE_LIMIT_EXCEEDED" : "GEOFENCE_ERROR"
588
+ call.reject(errorMsg, code)
589
+ }
590
+ )
591
+ }
592
+
593
+ @objc func removeGeofence(_ call: CAPPluginCall) {
594
+ guard guardGeofenceCall(call) else { return }
595
+
596
+ guard let identifier = call.getString("identifier") else {
597
+ call.reject("Missing required parameter: identifier")
598
+ return
599
+ }
600
+
601
+ geofenceManager.removeGeofence(identifier: identifier)
602
+ debug.logGeofenceRemove(identifier: identifier)
603
+
604
+ licenseEnforcer.stopTrialIfIdle(
605
+ hasGeofences: geofenceManager.hasGeofences,
606
+ isTracking: locationManager.isTracking
607
+ )
608
+
609
+ call.resolve()
610
+ }
611
+
612
+ @objc func removeAllGeofences(_ call: CAPPluginCall) {
613
+ guard guardGeofenceCall(call) else { return }
614
+
615
+ let count = geofenceManager.getGeofences().count
616
+ geofenceManager.removeAllGeofences()
617
+ debug.logGeofenceRemoveAll(count: count)
618
+
619
+ licenseEnforcer.stopTrialIfIdle(
620
+ hasGeofences: false,
621
+ isTracking: locationManager.isTracking
622
+ )
623
+
624
+ call.resolve()
625
+ }
626
+
627
+ @objc func getGeofences(_ call: CAPPluginCall) {
628
+ guard guardGeofenceCall(call) else { return }
629
+
630
+ let geofences = geofenceManager.getGeofences()
631
+ let result = geofences.map { $0.toDict() }
632
+ call.resolve(["geofences": result])
633
+ }
634
+
635
+ // MARK: - Trial Expiration
636
+
637
+ /// Called by BGLTrialTimer when the 30-minute trial period expires.
638
+ /// Auto-stops tracking and emits onTrialExpired event.
639
+ private func onTrialExpired() {
640
+ performStopTracking()
641
+ geofenceManager.removeAllGeofences()
642
+
643
+ let cooldownMinutes = BGLTrialTimer.cooldownDurationSeconds / 60
644
+ notifyListeners(
645
+ "onTrialExpired",
646
+ data: [
647
+ "elapsed": BGLTrialTimer.trialDurationSeconds,
648
+ "cooldownSeconds": BGLTrialTimer.cooldownDurationSeconds,
649
+ "message":
650
+ "Trial expired. Next start available in \(cooldownMinutes) minutes. Purchase a license for unlimited use.",
651
+ ])
652
+ print(
653
+ "BGLocation: Trial expired — tracking auto-stopped, cooldown started (\(BGLTrialTimer.cooldownDurationSeconds)s)"
654
+ )
655
+ }
656
+
657
+ }