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