@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,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
|
+
}
|