@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,163 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /// Result of license key validation.
5
+ public enum LicenseResult: Equatable {
6
+ case valid(updatesUntil: Int?)
7
+ case invalid(reason: String)
8
+ case updatesExpired(updatesUntil: Int)
9
+ }
10
+
11
+ /// License mode for the plugin.
12
+ public enum LicenseMode {
13
+ case full
14
+ case trial
15
+ }
16
+
17
+ /// Offline RSA-SHA256 license key validator.
18
+ ///
19
+ /// Key format: `BGL1-{base64url(payload)}.{base64url(signature)}`
20
+ /// Payload: `{ bid: String, iat: Int, exp?: Int }`
21
+ ///
22
+ /// Public key is embedded at compile time — private key never leaves the publisher.
23
+ public class BGLLicenseValidator {
24
+
25
+ private static let keyPrefix = "BGL1-"
26
+ private static let keyMaxObservedTime = "bgl_max_observed_time"
27
+
28
+ // RSA-2048 public key (SPKI / X.509 DER, base64-encoded)
29
+ private static let publicKeyBase64 =
30
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygeAaJHcA3s1DOsu4QWP"
31
+ + "NBNrK7gu6w+FhHMh5cVmy3YRkUrXblHnRCM1EZdfZb8/jX24rtyrrblF6sUDFDa/"
32
+ + "G/r1DeQUapmcVlbbJbQQbbRziVYQ3wA3N+/uZSJ08ac5CPw09yuIU2ytFkbnx27T"
33
+ + "nrRQ5Z8u9hSbW9e5YE9c5pG0yngD0IbW2Kz1ZVEH4TkLdNuhjaPr9wyA5Y3paOW3"
34
+ + "P0Fz9cpHj1eDVEYRZNlzvFuRMvxo2lVSX/FqNuRi4orlyGfHugLjqHihIymz6rVJ"
35
+ + "k+5lLyjgX6KLbKusn5OzblyedBwyJ6TcPWf7BBxZik6hWuqcv24reeCUMXO5t8eC" + "+wIDAQAB"
36
+
37
+ private let defaults: UserDefaults
38
+
39
+ public init(defaults: UserDefaults = .standard) {
40
+ self.defaults = defaults
41
+ }
42
+
43
+ /// Validate a license key against the given bundle ID.
44
+ ///
45
+ /// - Parameters:
46
+ /// - licenseKey: The GT1 license key string, or nil for trial.
47
+ /// - bundleId: The app's bundle identifier.
48
+ /// - buildEpoch: Unix epoch seconds when the plugin was built. Used for update gating.
49
+ /// - Returns: `.valid(updatesUntil:)`, `.updatesExpired(updatesUntil:)`, or `.invalid(reason:)`.
50
+ public func validate(licenseKey: String?, bundleId: String, buildEpoch: Int = 0)
51
+ -> LicenseResult
52
+ {
53
+ guard let licenseKey = licenseKey, !licenseKey.isEmpty else {
54
+ return .invalid(reason: "No license key provided")
55
+ }
56
+
57
+ guard licenseKey.hasPrefix(Self.keyPrefix) else {
58
+ return .invalid(reason: "Unsupported key format")
59
+ }
60
+
61
+ let body = String(licenseKey.dropFirst(Self.keyPrefix.count))
62
+ let parts = body.split(separator: ".", maxSplits: 1)
63
+ guard parts.count == 2 else {
64
+ return .invalid(reason: "Malformed key structure")
65
+ }
66
+
67
+ let payloadB64 = String(parts[0])
68
+ let signatureB64 = String(parts[1])
69
+
70
+ // Decode payload (base64url → Data)
71
+ guard let payloadData = base64UrlDecode(payloadB64) else {
72
+ return .invalid(reason: "Invalid payload encoding")
73
+ }
74
+
75
+ // Decode signature (base64url → Data)
76
+ guard let signatureData = base64UrlDecode(signatureB64) else {
77
+ return .invalid(reason: "Invalid signature encoding")
78
+ }
79
+
80
+ // Verify RSA-SHA256 signature
81
+ guard verifySignature(data: payloadData, signature: signatureData) else {
82
+ return .invalid(reason: "Invalid signature")
83
+ }
84
+
85
+ // Parse payload JSON
86
+ guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
87
+ else {
88
+ return .invalid(reason: "Invalid payload JSON")
89
+ }
90
+
91
+ guard let bid = json["bid"] as? String else {
92
+ return .invalid(reason: "Missing bid in payload")
93
+ }
94
+
95
+ // exp is optional — informational only ("updates available until")
96
+ let exp = json["exp"] as? Int
97
+
98
+ // Check bundle ID
99
+ guard bid == bundleId else {
100
+ return .invalid(reason: "Bundle ID mismatch")
101
+ }
102
+
103
+ // Update gating: if exp is set and build epoch is after exp, updates have expired
104
+ if let exp = exp, buildEpoch > 0, exp < buildEpoch {
105
+ return .updatesExpired(updatesUntil: exp)
106
+ }
107
+
108
+ return .valid(updatesUntil: exp)
109
+ }
110
+
111
+ /// High-water-mark: track the maximum observed time to prevent clock rollback attacks.
112
+ public func maxObservedTime(now: Int) -> Int {
113
+ let stored = defaults.integer(forKey: Self.keyMaxObservedTime)
114
+ let maxTime = max(now, stored)
115
+ if maxTime != stored {
116
+ defaults.set(maxTime, forKey: Self.keyMaxObservedTime)
117
+ }
118
+ return maxTime
119
+ }
120
+
121
+ // MARK: - Private
122
+
123
+ private func base64UrlDecode(_ string: String) -> Data? {
124
+ var base64 =
125
+ string
126
+ .replacingOccurrences(of: "-", with: "+")
127
+ .replacingOccurrences(of: "_", with: "/")
128
+
129
+ // Pad to multiple of 4
130
+ let remainder = base64.count % 4
131
+ if remainder > 0 {
132
+ base64 += String(repeating: "=", count: 4 - remainder)
133
+ }
134
+
135
+ return Data(base64Encoded: base64)
136
+ }
137
+
138
+ private func verifySignature(data: Data, signature: Data) -> Bool {
139
+ guard let keyData = Data(base64Encoded: Self.publicKeyBase64) else {
140
+ return false
141
+ }
142
+
143
+ let attributes: [CFString: Any] = [
144
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
145
+ kSecAttrKeyClass: kSecAttrKeyClassPublic,
146
+ kSecAttrKeySizeInBits: 2048,
147
+ ]
148
+
149
+ guard
150
+ let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil)
151
+ else {
152
+ return false
153
+ }
154
+
155
+ return SecKeyVerifySignature(
156
+ publicKey,
157
+ .rsaSignatureMessagePKCS1v15SHA256,
158
+ data as CFData,
159
+ signature as CFData,
160
+ nil
161
+ )
162
+ }
163
+ }
@@ -0,0 +1,168 @@
1
+ import Foundation
2
+
3
+ /// 30-minute trial timer with 1-hour cooldown.
4
+ ///
5
+ /// After the trial expires, a cooldown is persisted so that restarting the app
6
+ /// does not bypass the restriction. Clock manipulation is mitigated using
7
+ /// an anti-rollback guard on wall clock (iOS has no public monotonic clock API
8
+ /// that survives reboot, so we rely on `now < cooldownSetAt` detection).
9
+ public class BGLTrialTimer {
10
+
11
+ public static let trialDurationSeconds = 30 * 60 // 30 min
12
+ public static let cooldownDurationSeconds = 60 * 60 // 1 hour
13
+
14
+ static let keyCooldownEnds = "bgl_trial_cooldown_ends_at"
15
+ static let keyCooldownSet = "bgl_trial_cooldown_set_at"
16
+ static let keyCooldownMono = "bgl_trial_cooldown_mono_at"
17
+ static let keyTrialStartedAt = "bgl_trial_started_at"
18
+
19
+ private let defaults: UserDefaults
20
+ private let onExpired: () -> Void
21
+ private var timer: DispatchSourceTimer?
22
+
23
+ public private(set) var isRunning = false
24
+
25
+ /// Monotonic reference (ProcessInfo systemUptime) — does not survive reboot
26
+ /// but covers clock manipulation while the app is running.
27
+ private var monoStarted: TimeInterval = 0
28
+
29
+ public init(defaults: UserDefaults = .standard, onExpired: @escaping () -> Void) {
30
+ self.defaults = defaults
31
+ self.onExpired = onExpired
32
+ }
33
+
34
+ /// Start the 30-minute trial timer.
35
+ /// - Throws: `NSError` if cooldown is active (caller should check `isInCooldown()` first).
36
+ public func start() {
37
+ precondition(
38
+ !isInCooldown(),
39
+ "Trial cooldown active. \(remainingCooldownSeconds()) seconds remaining.")
40
+
41
+ isRunning = true
42
+ monoStarted = ProcessInfo.processInfo.systemUptime
43
+
44
+ // Persist the trial start time so that app restarts can detect an expired trial.
45
+ defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.keyTrialStartedAt)
46
+
47
+ let source = DispatchSource.makeTimerSource(queue: .main)
48
+ source.schedule(deadline: .now() + .seconds(Self.trialDurationSeconds))
49
+ source.setEventHandler { [weak self] in
50
+ self?.expire()
51
+ }
52
+ source.resume()
53
+ timer = source
54
+ }
55
+
56
+ /// Stop the timer without triggering cooldown (normal stop by user).
57
+ public func stop() {
58
+ timer?.cancel()
59
+ timer = nil
60
+ isRunning = false
61
+ defaults.removeObject(forKey: Self.keyTrialStartedAt)
62
+ }
63
+
64
+ /// Check whether the cooldown period is active.
65
+ ///
66
+ /// **Side effect (state repair):** If a previously started trial should have
67
+ /// expired while the app was inactive (killed/restarted), this method detects
68
+ /// the stale `keyTrialStartedAt` value and transitions the timer into cooldown.
69
+ /// This is intentional — the timer's in-memory state is lost on process death,
70
+ /// so `isInCooldown()` reconstructs it from persisted data.
71
+ public func isInCooldown() -> Bool {
72
+ // Check if a previous trial was started but expired while the app was inactive.
73
+ // If so, trigger the cooldown now.
74
+ let trialStartedAt = defaults.integer(forKey: Self.keyTrialStartedAt)
75
+ if trialStartedAt > 0 {
76
+ let nowSeconds = Int(Date().timeIntervalSince1970)
77
+ let elapsed = nowSeconds - trialStartedAt
78
+ if elapsed >= Self.trialDurationSeconds || nowSeconds < trialStartedAt {
79
+ // Trial should have expired — persist cooldown and clear trial start
80
+ defaults.removeObject(forKey: Self.keyTrialStartedAt)
81
+ persistCooldown()
82
+ isRunning = false
83
+ timer?.cancel()
84
+ timer = nil
85
+ return true
86
+ }
87
+ }
88
+
89
+ // Check monotonic time if available (within same app session)
90
+ let monoAt = defaults.double(forKey: Self.keyCooldownMono)
91
+ if monoAt > 0 {
92
+ let currentMono = ProcessInfo.processInfo.systemUptime
93
+ let elapsed = currentMono - monoAt
94
+ if elapsed < Double(Self.cooldownDurationSeconds) && elapsed >= 0 {
95
+ return true
96
+ }
97
+ }
98
+
99
+ let nowSeconds = Int(Date().timeIntervalSince1970)
100
+
101
+ // Anti-rollback guard: if current time is before the time we set cooldown,
102
+ // someone rolled back the clock → cooldown still active.
103
+ let setAt = defaults.integer(forKey: Self.keyCooldownSet)
104
+ if setAt > 0 && nowSeconds < setAt {
105
+ return true
106
+ }
107
+
108
+ // Check wall clock expiry
109
+ let endsAt = defaults.integer(forKey: Self.keyCooldownEnds)
110
+ if endsAt > 0 && nowSeconds < endsAt {
111
+ return true
112
+ }
113
+
114
+ // Cooldown is over — clear persisted values
115
+ if monoAt > 0 || setAt > 0 || endsAt > 0 {
116
+ clearCooldown()
117
+ }
118
+
119
+ return false
120
+ }
121
+
122
+ /// Remaining cooldown time in seconds (0 if not in cooldown).
123
+ public func remainingCooldownSeconds() -> Int {
124
+ // Try monotonic first
125
+ let monoAt = defaults.double(forKey: Self.keyCooldownMono)
126
+ if monoAt > 0 {
127
+ let currentMono = ProcessInfo.processInfo.systemUptime
128
+ let elapsed = currentMono - monoAt
129
+ let remaining = Double(Self.cooldownDurationSeconds) - elapsed
130
+ if remaining > 0 && elapsed >= 0 {
131
+ return Int(remaining)
132
+ }
133
+ }
134
+
135
+ // Fallback: wall clock
136
+ let endsAt = defaults.integer(forKey: Self.keyCooldownEnds)
137
+ let nowSeconds = Int(Date().timeIntervalSince1970)
138
+ let remaining = endsAt - nowSeconds
139
+ return remaining > 0 ? remaining : 0
140
+ }
141
+
142
+ // MARK: - Internal (visible for testing)
143
+
144
+ /// Exposed as `internal` so that `@testable import` tests can trigger expiry
145
+ /// without waiting for the real 30-minute timer.
146
+ func expire() {
147
+ isRunning = false
148
+ timer = nil
149
+ defaults.removeObject(forKey: Self.keyTrialStartedAt)
150
+ persistCooldown()
151
+ onExpired()
152
+ }
153
+
154
+ private func persistCooldown() {
155
+ let nowSeconds = Int(Date().timeIntervalSince1970)
156
+ let monoNow = ProcessInfo.processInfo.systemUptime
157
+ defaults.set(nowSeconds + Self.cooldownDurationSeconds, forKey: Self.keyCooldownEnds)
158
+ defaults.set(nowSeconds, forKey: Self.keyCooldownSet)
159
+ defaults.set(monoNow, forKey: Self.keyCooldownMono)
160
+ }
161
+
162
+ private func clearCooldown() {
163
+ defaults.removeObject(forKey: Self.keyCooldownEnds)
164
+ defaults.removeObject(forKey: Self.keyCooldownSet)
165
+ defaults.removeObject(forKey: Self.keyCooldownMono)
166
+ defaults.removeObject(forKey: Self.keyTrialStartedAt)
167
+ }
168
+ }
@@ -0,0 +1,91 @@
1
+ import Foundation
2
+
3
+ /// Configuration for the adaptive distance filter.
4
+ public struct AutoDistanceFilterConfig {
5
+ public static let defaultTargetInterval: Double = 10.0
6
+ public static let defaultMinDistance: Double = 10.0
7
+ public static let defaultMaxDistance: Double = 500.0
8
+
9
+ public let targetInterval: Double
10
+ public let minDistance: Double
11
+ public let maxDistance: Double
12
+
13
+ public init(
14
+ targetInterval: Double = Self.defaultTargetInterval,
15
+ minDistance: Double = Self.defaultMinDistance,
16
+ maxDistance: Double = Self.defaultMaxDistance
17
+ ) {
18
+ self.targetInterval = targetInterval
19
+ self.minDistance = minDistance
20
+ self.maxDistance = maxDistance
21
+ }
22
+ }
23
+
24
+ /// Adaptive distance filter that dynamically adjusts based on speed.
25
+ ///
26
+ /// Uses Exponential Moving Average (EMA) to smooth GPS speed readings
27
+ /// and calculates an effective distance filter so that location updates
28
+ /// arrive at approximately `config.targetInterval` seconds regardless of speed.
29
+ ///
30
+ /// Formula: `effectiveDistance = clamp(smoothedSpeed * targetInterval, minDistance, maxDistance)`
31
+ ///
32
+ /// The native distance filter is only reconfigured when the change exceeds `recalcThreshold`
33
+ /// to avoid unnecessary reconfiguration overhead.
34
+ public class BGLAdaptiveFilter {
35
+
36
+ /// EMA smoothing factor for acceleration. Lower = smoother but slower to react.
37
+ public static let emaAlphaUp: Double = 0.3
38
+
39
+ /// EMA smoothing factor for deceleration. Higher = faster reaction to speed drops.
40
+ public static let emaAlphaDown: Double = 0.6
41
+
42
+ /// Minimum change in meters to trigger a native distance filter reconfiguration.
43
+ public static let recalcThreshold: Double = 5.0
44
+
45
+ private let config: AutoDistanceFilterConfig
46
+ private var smoothedSpeed: Double = 0.0
47
+ private var initialized = false
48
+
49
+ /// The current effective distance filter in meters.
50
+ public private(set) var effectiveDistanceFilter: Double
51
+
52
+ public init(config: AutoDistanceFilterConfig = AutoDistanceFilterConfig()) {
53
+ self.config = config
54
+ self.effectiveDistanceFilter = config.minDistance
55
+ }
56
+
57
+ /// Update the filter with a new speed reading and determine if the native
58
+ /// distance filter should be reconfigured.
59
+ ///
60
+ /// - Parameter speedMps: Current speed in meters per second. Negative values (no GPS speed) are treated as 0.
61
+ /// - Returns: `true` if the native distance filter should be updated (change exceeds threshold),
62
+ /// `false` if no reconfiguration is needed.
63
+ public func update(speedMps: Double) -> Bool {
64
+ let safeSpeed = speedMps.isNaN ? 0.0 : max(speedMps, 0.0)
65
+
66
+ if !initialized {
67
+ initialized = true
68
+ smoothedSpeed = safeSpeed
69
+ } else {
70
+ let alpha = safeSpeed < smoothedSpeed ? Self.emaAlphaDown : Self.emaAlphaUp
71
+ smoothedSpeed = alpha * safeSpeed + (1.0 - alpha) * smoothedSpeed
72
+ }
73
+
74
+ let rawDistance = smoothedSpeed * config.targetInterval
75
+ let clamped = min(max(rawDistance, config.minDistance), config.maxDistance)
76
+
77
+ let delta = abs(clamped - effectiveDistanceFilter)
78
+ if delta >= Self.recalcThreshold {
79
+ effectiveDistanceFilter = clamped
80
+ return true
81
+ }
82
+ return false
83
+ }
84
+
85
+ /// Reset the filter state. Should be called when tracking stops.
86
+ public func reset() {
87
+ smoothedSpeed = 0.0
88
+ effectiveDistanceFilter = config.minDistance
89
+ initialized = false
90
+ }
91
+ }
@@ -0,0 +1,50 @@
1
+ import Foundation
2
+
3
+ /// Repeating timer for heartbeat events using DispatchSourceTimer.
4
+ /// Runs on a dedicated `.utility` QoS queue to survive background execution.
5
+ public class BGLHeartbeatTimer {
6
+
7
+ private let intervalSeconds: Int
8
+ private let onTick: () -> Void
9
+ private var timer: DispatchSourceTimer?
10
+ private let queue = DispatchQueue(
11
+ label: "dev.bglocation.heartbeat",
12
+ qos: .utility
13
+ )
14
+ public private(set) var isRunning = false
15
+
16
+ public init(intervalSeconds: Int, onTick: @escaping () -> Void) {
17
+ self.intervalSeconds = intervalSeconds
18
+ self.onTick = onTick
19
+ }
20
+
21
+ /// Start the repeating timer.
22
+ public func start() {
23
+ stop()
24
+
25
+ let interval = TimeInterval(intervalSeconds)
26
+ let source = DispatchSource.makeTimerSource(queue: queue)
27
+ source.schedule(
28
+ deadline: .now() + interval,
29
+ repeating: interval,
30
+ leeway: .seconds(1)
31
+ )
32
+ source.setEventHandler { [weak self] in
33
+ self?.onTick()
34
+ }
35
+ source.resume()
36
+ timer = source
37
+ isRunning = true
38
+ }
39
+
40
+ /// Stop and cancel the timer.
41
+ public func stop() {
42
+ timer?.cancel()
43
+ timer = nil
44
+ isRunning = false
45
+ }
46
+
47
+ deinit {
48
+ stop()
49
+ }
50
+ }
@@ -0,0 +1,48 @@
1
+ import Foundation
2
+
3
+ /// Typed location data model for iOS.
4
+ ///
5
+ /// Replaces untyped `[String: Any]` dictionaries throughout the plugin's
6
+ /// internal layer (buffer, HTTP sender, helpers). Conversion to `[String: Any]`
7
+ /// happens only at the Capacitor bridge boundary (event emission).
8
+ public struct BGLLocationData: Equatable {
9
+ public let latitude: Double
10
+ public let longitude: Double
11
+ public let accuracy: Double
12
+ public let speed: Double
13
+ public let heading: Double
14
+ public let altitude: Double
15
+ public let timestamp: Double
16
+ public let isMoving: Bool
17
+ public let isMock: Bool
18
+
19
+ public init(
20
+ latitude: Double, longitude: Double, accuracy: Double, speed: Double, heading: Double,
21
+ altitude: Double, timestamp: Double, isMoving: Bool, isMock: Bool
22
+ ) {
23
+ self.latitude = latitude
24
+ self.longitude = longitude
25
+ self.accuracy = accuracy
26
+ self.speed = speed
27
+ self.heading = heading
28
+ self.altitude = altitude
29
+ self.timestamp = timestamp
30
+ self.isMoving = isMoving
31
+ self.isMock = isMock
32
+ }
33
+
34
+ /// Convert to a dictionary suitable for the Capacitor JS bridge.
35
+ public func toDict() -> [String: Any] {
36
+ return [
37
+ "latitude": latitude,
38
+ "longitude": longitude,
39
+ "accuracy": accuracy,
40
+ "speed": speed,
41
+ "heading": heading,
42
+ "altitude": altitude,
43
+ "timestamp": timestamp,
44
+ "isMoving": isMoving,
45
+ "isMock": isMock,
46
+ ]
47
+ }
48
+ }
@@ -0,0 +1,42 @@
1
+ import CoreLocation
2
+
3
+ /// Pure helper functions extracted for testability.
4
+ /// These functions depend only on CoreLocation — no Capacitor dependency.
5
+ public enum BGLLocationHelpers {
6
+
7
+ /// Convert CLLocation to a typed BGLLocationData model.
8
+ public static func mapLocation(_ location: CLLocation, isMoving: Bool) -> BGLLocationData {
9
+ return BGLLocationData(
10
+ latitude: location.coordinate.latitude,
11
+ longitude: location.coordinate.longitude,
12
+ accuracy: location.horizontalAccuracy,
13
+ speed: max(location.speed, 0),
14
+ heading: location.course >= 0 ? location.course : -1.0,
15
+ altitude: location.altitude,
16
+ timestamp: location.timestamp.timeIntervalSince1970 * 1000,
17
+ isMoving: isMoving,
18
+ isMock: false // iOS does not expose mock location detection
19
+ )
20
+ }
21
+
22
+ /// Convert CLLocation to a dictionary suitable for the JS bridge.
23
+ /// Convenience wrapper for backward compatibility at the Capacitor boundary.
24
+ public static func locationToDict(_ location: CLLocation, isMoving: Bool) -> [String: Any] {
25
+ return mapLocation(location, isMoving: isMoving).toDict()
26
+ }
27
+
28
+ /// Map a string accuracy value to CLLocationAccuracy.
29
+ public static func mapAccuracy(_ value: String) -> CLLocationAccuracy {
30
+ switch value {
31
+ case "high":
32
+ return kCLLocationAccuracyBest
33
+ case "balanced":
34
+ return kCLLocationAccuracyHundredMeters
35
+ case "low":
36
+ return kCLLocationAccuracyKilometer
37
+ default:
38
+ print("BGLocation: Unknown desiredAccuracy '\(value)', falling back to 'high'")
39
+ return kCLLocationAccuracyBest
40
+ }
41
+ }
42
+ }