@bricks-soft/capacitor-location-tracking 8.1.0 → 8.2.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/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +107 -29
  4. package/android/build.gradle +3 -1
  5. package/dist/esm/definitions.d.ts +10 -4
  6. package/dist/esm/definitions.js.map +1 -1
  7. package/dist/esm/web/device.d.ts +1 -1
  8. package/dist/esm/web/device.js +1 -1
  9. package/dist/esm/web/device.js.map +1 -1
  10. package/dist/plugin.cjs.js +1 -1
  11. package/dist/plugin.cjs.js.map +1 -1
  12. package/dist/plugin.js +1 -1
  13. package/dist/plugin.js.map +1 -1
  14. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  15. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  16. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  17. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  18. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  19. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  20. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  21. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  22. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  23. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  24. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  25. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  26. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  27. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  28. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  29. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  30. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  31. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  32. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  33. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  34. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  35. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  36. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  37. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  38. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  39. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  40. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  41. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  42. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  43. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  44. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  45. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  46. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  47. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  48. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  49. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  50. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  51. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  52. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  53. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  54. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  55. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  56. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  57. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  58. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  59. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  60. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  61. package/package.json +9 -3
@@ -0,0 +1,313 @@
1
+ import Foundation
2
+
3
+ final class Syncer {
4
+ let transport = HTTPTransport()
5
+ private let queue: DispatchQueue
6
+ private let store: TrackingStore
7
+ private let config: TrackingConfig
8
+ private let emit: (String, TrackingJSON) -> Void
9
+ private var callbacks: [(Result<[TrackingJSON], Error>) -> Void] = []
10
+ private var followUp: [(Result<[TrackingJSON], Error>) -> Void] = []
11
+ private var connectivityWaiters: [() -> Void] = []
12
+ private var uploaded: [TrackingJSON] = []
13
+ private var reducedBatch: Int?
14
+ private var busy = false
15
+ private var closed = false
16
+ private var pendingAutomatic = false
17
+ private var pendingRecovery = false
18
+ private var changedUntil: Date?
19
+ private var rejectedPriority = Set<String>()
20
+ private var passFailure: (Error, TrackingJSON, Int, Double?)?
21
+ private enum Renewal { case renewed(TrackingJSON), changed, failed(Error) }
22
+ var connectivityKnown = false
23
+ private var priorityOnly = false
24
+ private var generation = 0
25
+ private var shortLivedToken: String?
26
+ private var failures = 0
27
+ private var retryTimer: DispatchWorkItem?
28
+ var connected = true
29
+ var cellular = false
30
+ var tracking = false
31
+ private let priority = Set(["heartbeat", "tracking_start", "tracking_stop", "providerchange"])
32
+ init(queue: DispatchQueue, store: TrackingStore, config: TrackingConfig, emit: @escaping (String, TrackingJSON) -> Void) {
33
+ self.queue = queue; self.store = store; self.config = config; self.emit = emit
34
+ failures = Int((try? store.load("uploadWait"))?.number("failures") ?? 0)
35
+ }
36
+ static func needsRefresh(_ auth: TrackingJSON, now: Double, shortLivedToken: String? = nil) -> Bool {
37
+ let token = auth.text("accessToken")
38
+ if token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
39
+ let expires = auth.number("expires", -1)
40
+ if expires <= 0 { return false }
41
+ return now >= expires || (now >= expires - 60000 && token != shortLivedToken)
42
+ }
43
+ func close() {
44
+ guard !closed else { return }
45
+ closed = true
46
+ retryTimer?.cancel()
47
+ cancel()
48
+ transport.close()
49
+ let waiters = connectivityWaiters; connectivityWaiters = []
50
+ waiters.forEach { $0() }
51
+ }
52
+ func cancel() {
53
+ generation += 1
54
+ pendingAutomatic = false
55
+ transport.cancel()
56
+ if busy { finish(.failure(TrackingError("NETWORK_ERROR", "Upload cancelled"))) }
57
+ }
58
+ func connectivity(connected: Bool, cellular: Bool) {
59
+ let recovered = connectivityKnown && !self.connected && connected
60
+ connectivityKnown = true
61
+ self.connected = connected
62
+ self.cellular = cellular
63
+ if recovered && busy { pendingRecovery = true }
64
+ if recovered, let wait = try? store.load("uploadWait"), wait.text("reason") == "server" {
65
+ try? store.save("uploadWait", [:])
66
+ failures = 0
67
+ retryTimer?.cancel()
68
+ }
69
+ let waiters = connectivityWaiters; connectivityWaiters = []
70
+ waiters.forEach { $0() }
71
+ automatic()
72
+ }
73
+ func whenConnectivityKnown(_ body: @escaping () -> Void) {
74
+ if connectivityKnown || closed { body() } else { connectivityWaiters.append(body) }
75
+ }
76
+ func automatic(completion: ((Result<[TrackingJSON], Error>) -> Void)? = nil) {
77
+ guard !closed else { completion?(.failure(TrackingError("NETWORK_ERROR", "Syncer is closed"))); return }
78
+ if busy {
79
+ pendingAutomatic = true
80
+ if let completion { followUp.append(completion) }
81
+ return
82
+ }
83
+ guard connectivityKnown && connected else { completion?(.success([])); return }
84
+ if let until = changedUntil, until > Date() { schedule(until.timeIntervalSince1970 * 1000); completion?(.success([])); return }
85
+ changedUntil = nil
86
+ let total: Int, hasPriority: Bool
87
+ do {
88
+ total = try store.count
89
+ hasPriority = try total > 0 && store.contains(events: priority)
90
+ } catch { completion?(.failure(error)); return }
91
+ guard total > 0 else { completion?(.success([])); return }
92
+ let http = config.value.object("http")
93
+ guard !http.text("url").isEmpty else { completion?(.success([])); return }
94
+ if hasPriority { run(priorityOnly: cellular && http.flag("disableAutoSyncOnCellular"), completion: completion ?? { _ in }); return }
95
+ guard http.flag("autoSync", true), !(cellular && http.flag("disableAutoSyncOnCellular")) else { completion?(.success([])); return }
96
+ if let wait = try? store.load("uploadWait"), wait.number("at") > Date().timeIntervalSince1970 * 1000,
97
+ wait.text("reason") != "authorization" || wait.text("authKey") == UploadBackoff.authKey(http) { schedule(wait.number("at")); completion?(.success([])); return }
98
+ let interval = tracking ? http.number("syncInterval") : 0
99
+ let threshold = http.number("autoSyncThreshold")
100
+ if interval > 0, let oldest = try? store.locations(limit: 1).first {
101
+ let age = Date().timeIntervalSince(isoDate(oldest.text("recorded_at")) ?? .distantPast)
102
+ if age >= 0 && age < interval && !(threshold > 0 && Double(total) >= threshold) { schedule(Date().timeIntervalSince1970 * 1000 + (interval - age) * 1000); completion?(.success([])); return }
103
+ } else if Double(total) < max(1, threshold) { completion?(.success([])); return }
104
+ run(completion: completion ?? { _ in })
105
+ }
106
+ func run(priorityOnly: Bool = false, completion: @escaping (Result<[TrackingJSON], Error>) -> Void) {
107
+ guard !closed else { completion(.failure(TrackingError("NETWORK_ERROR", "Syncer is closed"))); return }
108
+ guard !config.value.object("http").text("url").isEmpty else { completion(.failure(TrackingError("NO_URL", "No http.url configured"))); return }
109
+ callbacks.append(completion)
110
+ if busy { if !priorityOnly { self.priorityOnly = false }; return }
111
+ self.priorityOnly = priorityOnly
112
+ busy = true; uploaded = []; reducedBatch = nil; rejectedPriority = []; passFailure = nil
113
+ next()
114
+ }
115
+ private func next() {
116
+ do {
117
+ let http = config.value.object("http")
118
+ let limit = http.flag("batchSync") ? max(1, Int(http.number("maxBatchSize", 100))) : 1
119
+ let size = reducedBatch.map { min(limit, $0) } ?? limit
120
+ let chunk = priorityOnly ? Array(try store.locations(events: priority).filter { !rejectedPriority.contains($0.text("uuid")) }.prefix(size)) : try store.locations(limit: size)
121
+ if chunk.isEmpty {
122
+ if let failure = passFailure { fail(failure.0, http: failure.1, status: failure.2, retryAfter: failure.3, authorizationFailure: false) }
123
+ else {
124
+ if !priorityOnly { failures = 0; try store.save("uploadWait", [:]) }
125
+ finish(.success(uploaded))
126
+ }
127
+ return
128
+ }
129
+ upload(chunk, http: http, attempt: 0, refreshed: false)
130
+ } catch { finish(.failure(error)) }
131
+ }
132
+ private func upload(_ records: [TrackingJSON], http: TrackingJSON, attempt: Int, refreshed: Bool) {
133
+ let captured = generation
134
+ let explicitAuthorization = http.object("headers").keys.contains { $0.lowercased() == "authorization" }
135
+ if config.secretsUnavailable && !explicitAuthorization {
136
+ emit("authorization", ["success": false, "status": 0, "error": "Stored authorization is unavailable; set http.authorization again"])
137
+ fail(TrackingError("HTTP_ERROR", "Stored authorization is unavailable; set http.authorization again"), http: http, status: 401, retryAfter: nil)
138
+ return
139
+ }
140
+ guard UploadBackoff.authKey(http) == UploadBackoff.authKey(config.value.object("http")) else { credentialsChanged(); return }
141
+ let auth = explicitAuthorization ? [:] : http.object("authorization")
142
+ if !auth.isEmpty && !auth.text("refreshUrl").isEmpty && !refreshed && Self.needsRefresh(auth, now: Date().timeIntervalSince1970 * 1000, shortLivedToken: shortLivedToken) {
143
+ refresh(http) { result in
144
+ guard self.generation == captured else { return }
145
+ switch result {
146
+ case .renewed(let fresh): self.upload(records, http: fresh, attempt: attempt, refreshed: true)
147
+ case .changed: self.credentialsChanged()
148
+ case .failed(let error):
149
+ if !auth.text("accessToken").isEmpty { self.upload(records, http: http, attempt: attempt, refreshed: true) }
150
+ else { self.fail(error, http: http, status: 401, retryAfter: nil) }
151
+ }
152
+ }
153
+ return
154
+ }
155
+ do {
156
+ guard let url = URL(string: http.text("url")), ["http", "https"].contains(url.scheme ?? "") else { throw TrackingError("INVALID_ARGUMENT", "Invalid upload URL") }
157
+ var headers = http["headers"] as? [String: String] ?? [:]
158
+ if !auth.text("accessToken").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { headers = headers.filter { $0.key.lowercased() != "authorization" }; headers["Authorization"] = "Bearer " + auth.text("accessToken") }
159
+ let body = try BodyBuilder.build(records, http: http)
160
+ transport.send(url: url, method: http.text("method", "POST"), headers: headers, body: body, timeout: http.number("timeout", 60000)) { result in
161
+ self.queue.async {
162
+ guard self.generation == captured, self.busy else { return }
163
+ let response = try? result.get()
164
+ let status = response?.status ?? 0
165
+ self.emit("http", ["success": response?.success ?? false, "status": status, "responseText": response.map { String(decoding: $0.body, as: UTF8.self) } ?? "Network request failed", "uuids": records.map { $0.text("uuid") }])
166
+ if response?.success == true {
167
+ do {
168
+ try self.store.transaction { for record in records { try self.store.delete(record.text("uuid")) } }
169
+ self.uploaded += records
170
+ self.next()
171
+ } catch { self.finish(.failure(error)) }
172
+ return
173
+ }
174
+ if UploadBackoff.authKey(http) != UploadBackoff.authKey(self.config.value.object("http")) {
175
+ self.credentialsChanged(); return
176
+ }
177
+ if status == 401 && !refreshed && !auth.isEmpty {
178
+ self.refresh(http) { renewal in
179
+ guard self.generation == captured else { return }
180
+ switch renewal {
181
+ case .renewed(let fresh): self.upload(records, http: fresh, attempt: attempt, refreshed: true)
182
+ case .changed: self.credentialsChanged()
183
+ case .failed(let error): self.fail(error, http: http, status: 401, retryAfter: nil)
184
+ }
185
+ }
186
+ return
187
+ }
188
+ if status == 413 && records.count > 1 { self.reducedBatch = max(1, records.count / 2); self.next(); return }
189
+ let retryAfter = (status == 429 || status == 503) ? UploadBackoff.retryAfter(response?.retryAfter, maximum: http.number("maxRetryAfter", 3600)) : nil
190
+ if (status == 0 || status == 429 || status >= 500), attempt < 3 {
191
+ let delay = UploadBackoff.retryDelay(attempt, random: Double.random(in: 0..<1))
192
+ if (retryAfter ?? 0) <= delay {
193
+ self.queue.asyncAfter(deadline: .now() + delay / 1000) {
194
+ guard self.generation == captured, self.busy else { return }
195
+ self.upload(records, http: http, attempt: attempt + 1, refreshed: refreshed)
196
+ }
197
+ return
198
+ }
199
+ }
200
+ self.reject(records, error: TrackingError(status == 0 ? "NETWORK_ERROR" : "HTTP_ERROR", status == 0 ? "Network request failed" : "Upload failed: HTTP \(status)"), http: http, status: status, retryAfter: retryAfter)
201
+ }
202
+ }
203
+ } catch { reject(records, error: error, http: http, status: -1, retryAfter: nil) }
204
+ }
205
+ private func refresh(_ http: TrackingJSON, completion: @escaping (Renewal) -> Void) {
206
+ let auth = http.object("authorization")
207
+ guard let url = URL(string: auth.text("refreshUrl")), ["http", "https"].contains(url.scheme ?? "") else { emit("authorization", ["success": false, "status": 0, "error": "Token refresh is not configured"]); completion(.failed(TrackingError("HTTP_ERROR", "Token refresh is not configured"))); return }
208
+ var payload = auth.object("refreshPayload")
209
+ for (key, value) in payload { if let text = value as? String { payload[key] = text.replacingOccurrences(of: "{refreshToken}", with: auth.text("refreshToken")) } }
210
+ do {
211
+ var headers = auth["refreshHeaders"] as? [String: String] ?? [:]
212
+ let body: Data
213
+ if auth.text("refreshPayloadEncoding", "json") == "form" {
214
+ var components = URLComponents()
215
+ components.queryItems = payload.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) }
216
+ body = Data((components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B").utf8)
217
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
218
+ } else { body = try jsonData(payload) }
219
+ let captured = generation
220
+ transport.send(url: url, method: "POST", headers: headers, body: body, timeout: http.number("timeout", 60000), responseLimit: nil) { result in
221
+ self.queue.async {
222
+ guard captured == self.generation else { return }
223
+ guard UploadBackoff.authKey(http) == UploadBackoff.authKey(self.config.value.object("http")) else { completion(.changed); return }
224
+ let response = try? result.get()
225
+ let json = response.flatMap { try? jsonObject($0.body) }
226
+ var event: TrackingJSON = ["success": false, "status": response?.status ?? 0]
227
+ if let json { event["response"] = json }
228
+ do {
229
+ guard let response, let json else { throw TrackingError("HTTP_ERROR", "Token refresh failed") }
230
+ let access = json.text("accessToken", json.text("access_token"))
231
+ guard response.success, !access.isEmpty else { throw TrackingError("HTTP_ERROR", "Token refresh failed") }
232
+ var next = auth
233
+ next["accessToken"] = access
234
+ next["refreshToken"] = json.text("refreshToken", json.text("refresh_token", auth.text("refreshToken")))
235
+ var expires = json.number("expires", json.number("expires_at", -1))
236
+ if expires > 0 && expires < 1e12 { expires *= 1000 }
237
+ if expires <= 0 && json.number("expires_in") > 0 { expires = Date().timeIntervalSince1970 * 1000 + json.number("expires_in") * 1000 }
238
+ next["expires"] = expires
239
+ try self.config.apply(["http": ["authorization": next]], reset: false)
240
+ if expires > 0 && expires - Date().timeIntervalSince1970 * 1000 <= 60000 { self.shortLivedToken = access }
241
+ event["success"] = true
242
+ self.emit("authorization", event)
243
+ completion(.renewed(self.config.value.object("http")))
244
+ } catch {
245
+ event["error"] = "Token refresh failed"
246
+ self.emit("authorization", event)
247
+ completion(.failed(error))
248
+ }
249
+ }
250
+ }
251
+ } catch {
252
+ emit("authorization", ["success": false, "status": 0, "error": "Token refresh failed"])
253
+ completion(.failed(error))
254
+ }
255
+ }
256
+ private func credentialsChanged() {
257
+ retryTimer?.cancel()
258
+ let current = config.value.object("http")
259
+ let hasReplacement = current["authorization"] is TrackingJSON || current.object("headers").keys.contains { $0.lowercased() == "authorization" }
260
+ if hasReplacement {
261
+ changedUntil = Date().addingTimeInterval(10)
262
+ schedule(changedUntil!.timeIntervalSince1970 * 1000)
263
+ }
264
+ pendingAutomatic = false
265
+ finish(.failure(TrackingError("HTTP_ERROR", "Authorization changed; records retained")))
266
+ }
267
+ private func reject(_ records: [TrackingJSON], error: Error, http: TrackingJSON, status: Int, retryAfter: Double?) {
268
+ let usesJWT = !http.object("headers").keys.contains { $0.lowercased() == "authorization" } && !http.object("authorization").isEmpty
269
+ if status != 0 && !(status == 401 && usesJWT) && (!priorityOnly || passFailure != nil) {
270
+ if passFailure == nil { passFailure = (error, http, status, retryAfter) }
271
+ if records.allSatisfy({ priority.contains($0.text("event")) }) { rejectedPriority.formUnion(records.map { $0.text("uuid") }) }
272
+ priorityOnly = true
273
+ next()
274
+ } else { fail(error, http: http, status: status, retryAfter: retryAfter, authorizationFailure: status == 401 && usesJWT) }
275
+ }
276
+ private func fail(_ error: Error, http: TrackingJSON, status: Int, retryAfter: Double?, authorizationFailure: Bool? = nil) {
277
+ let authFailure = authorizationFailure ?? (status == 401)
278
+ failures += 1
279
+ let delay = authFailure ? 900000 : UploadBackoff.serverWait(failures, random: Double.random(in: 0..<1), retryAfter: retryAfter, minimum: tracking ? http.number("syncInterval") * 1000 : 0)
280
+ let at = Date().timeIntervalSince1970 * 1000 + delay
281
+ do { try store.save("uploadWait", ["at": at, "reason": authFailure ? "authorization" : "server", "failures": failures, "authKey": UploadBackoff.authKey(http)]) }
282
+ catch { finish(.failure(error)); return }
283
+ schedule(at)
284
+ finish(.failure(error))
285
+ }
286
+ private func schedule(_ at: Double) {
287
+ retryTimer?.cancel()
288
+ let item = DispatchWorkItem { [weak self] in self?.automatic() }
289
+ retryTimer = item
290
+ queue.asyncAfter(deadline: .now() + max(0.1, at / 1000 - Date().timeIntervalSince1970), execute: item)
291
+ }
292
+ private func finish(_ result: Result<[TrackingJSON], Error>) {
293
+ busy = false
294
+ let waiting = callbacks; callbacks = []
295
+ waiting.forEach { $0(result) }
296
+ if pendingRecovery {
297
+ if let wait = try? store.load("uploadWait"), wait.text("reason") == "server" { try? store.save("uploadWait", [:]); failures = 0 }
298
+ pendingRecovery = false
299
+ }
300
+ let requested = pendingAutomatic
301
+ pendingAutomatic = false
302
+ let later = followUp; followUp = []
303
+ if requested {
304
+ let completion: ((Result<[TrackingJSON], Error>) -> Void)? = later.isEmpty ? nil : { value in later.forEach { $0(value) } }
305
+ queue.async { [weak self] in
306
+ if let self { self.automatic(completion: completion) } else { completion?(.failure(TrackingError("NETWORK_ERROR", "Syncer is closed"))) }
307
+ }
308
+ } else {
309
+ later.forEach { $0(result) }
310
+ if case .success = result { queue.async { [weak self] in self?.automatic() } }
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ struct UploadBackoff {
5
+ static func jittered(_ base: Double, random: Double) -> Double { floor(base * (0.8 + 0.4 * random)) }
6
+ static func retryDelay(_ index: Int, random: Double) -> Double { jittered([2000, 4000, 8000][index], random: random) }
7
+ static func serverWait(_ failures: Int, random: Double, retryAfter: Double?, minimum: Double) -> Double {
8
+ max(jittered(min(30000 * pow(2, Double(max(0, min(20, failures - 1)))), 900000), random: random), retryAfter ?? 0, minimum)
9
+ }
10
+ static func retryAfter(_ text: String?, maximum: Double, now: Date = Date()) -> Double? {
11
+ guard let text else { return nil }
12
+ let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
13
+ if let seconds = Double(cleaned), seconds >= 0 { return min(seconds, maximum) * 1000 }
14
+ let format = DateFormatter()
15
+ format.locale = Locale(identifier: "en_US_POSIX")
16
+ format.timeZone = TimeZone(secondsFromGMT: 0)
17
+ format.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
18
+ guard let date = format.date(from: cleaned) else { return nil }
19
+ return min(max(0, date.timeIntervalSince(now)), maximum) * 1000
20
+ }
21
+ static func authKey(_ http: TrackingJSON) -> String {
22
+ if http.object("headers").keys.contains(where: { $0.lowercased() == "authorization" }) { return "explicit-header" }
23
+ let auth = http.object("authorization")
24
+ return SHA256.hash(data: Data((auth.text("accessToken") + "\0" + auth.text("refreshToken")).utf8)).prefix(12).map { String(format: "%02x", $0) }.joined()
25
+ }
26
+ }
@@ -0,0 +1,124 @@
1
+ import Foundation
2
+ import CoreLocation
3
+ import CoreMotion
4
+ import UIKit
5
+
6
+ struct RouteProfile: Equatable {
7
+ var enabled: Bool
8
+ var moving: Bool
9
+ var accuracy: String
10
+ var activityEnabled: Bool
11
+ var sessionID = ""
12
+ }
13
+
14
+ final class RouteSource: NSObject, CLLocationManagerDelegate {
15
+ private let manager: CLLocationManager
16
+ private let relaunch: CLLocationManager
17
+ private let motion = CMMotionActivityManager()
18
+ private var profile = RouteProfile(enabled: false, moving: false, accuracy: "high", activityEnabled: false)
19
+ private var activityRunning = false
20
+ private var cachedLocation: TrackedLocation?
21
+ private let clock: () -> TrackingInstant
22
+ private var timebase: Double?
23
+ var locations: (([TrackedLocation], String) -> Void)?
24
+ var error: ((TrackingError, String) -> Void)?
25
+ var activity: ((ActivitySample, String) -> Void)?
26
+ var authorizationChanged: (() -> Void)?
27
+ var lastLocation: TrackedLocation? {
28
+ precondition(Thread.isMainThread)
29
+ return cachedLocation
30
+ }
31
+ init(manager: CLLocationManager = CLLocationManager(), relaunch: CLLocationManager = CLLocationManager(), clock: @escaping () -> TrackingInstant = TrackingClock.now) {
32
+ self.manager = manager; self.relaunch = relaunch; self.clock = clock
33
+ super.init()
34
+ manager.delegate = self; relaunch.delegate = self
35
+ manager.distanceFilter = kCLDistanceFilterNone
36
+ manager.pausesLocationUpdatesAutomatically = false
37
+ manager.showsBackgroundLocationIndicator = true
38
+ }
39
+ func apply(_ new: RouteProfile, force: Bool = false) {
40
+ precondition(Thread.isMainThread)
41
+ guard new != profile || force else { return }
42
+ if new.sessionID != profile.sessionID { stopMotion() }
43
+ profile = new
44
+ guard new.enabled else { manager.stopUpdatingLocation(); relaunch.stopMonitoringSignificantLocationChanges(); stopMotion(); return }
45
+ guard manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else {
46
+ manager.stopUpdatingLocation(); relaunch.stopMonitoringSignificantLocationChanges(); stopMotion(); return
47
+ }
48
+ let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] ?? []
49
+ manager.allowsBackgroundLocationUpdates = modes.contains("location")
50
+ manager.desiredAccuracy = new.moving ? Self.accuracy(new.accuracy) : kCLLocationAccuracyHundredMeters
51
+ if force { manager.stopUpdatingLocation() }
52
+ manager.startUpdatingLocation()
53
+ if manager.authorizationStatus == .authorizedAlways && CLLocationManager.significantLocationChangeMonitoringAvailable() { relaunch.startMonitoringSignificantLocationChanges() }
54
+ else { relaunch.stopMonitoringSignificantLocationChanges() }
55
+ if new.activityEnabled && CMMotionActivityManager.isActivityAvailable() && CMMotionActivityManager.authorizationStatus() == .authorized {
56
+ if !activityRunning {
57
+ activityRunning = true
58
+ let session = new.sessionID
59
+ motion.startActivityUpdates(to: .main) { [weak self] sample in
60
+ guard let self, self.profile.enabled, self.profile.activityEnabled, self.activityRunning, self.profile.sessionID == session, let sample else { return }
61
+ let confidence: Int
62
+ switch sample.confidence { case .low: confidence = 33; case .medium: confidence = 66; case .high: confidence = 100; @unknown default: confidence = 0 }
63
+ self.activity?(ActivitySample.map(automotive: sample.automotive, cycling: sample.cycling, running: sample.running, walking: sample.walking, stationary: sample.stationary, confidence: confidence), session)
64
+ }
65
+ }
66
+ } else { stopMotion() }
67
+ }
68
+ private func stopMotion() { if activityRunning { activityRunning = false; motion.stopActivityUpdates() } }
69
+ static func accuracy(_ desired: String) -> CLLocationAccuracy {
70
+ switch desired { case "balanced": return kCLLocationAccuracyHundredMeters; case "low": return kCLLocationAccuracyKilometer; case "passive": return kCLLocationAccuracyThreeKilometers; default: return kCLLocationAccuracyBest }
71
+ }
72
+ func locationManager(_ source: CLLocationManager, didUpdateLocations fixes: [CLLocation]) {
73
+ guard source === manager else { return }
74
+ let valid = ordered(fixes.filter { $0.horizontalAccuracy >= 0 && CLLocationCoordinate2DIsValid($0.coordinate) })
75
+ if let newest = valid.reversed().max(by: { $0.isOlder(than: $1) }), cachedLocation == nil || !newest.isOlder(than: cachedLocation!) { cachedLocation = newest }
76
+ guard profile.enabled else { return }
77
+ locations?(valid, profile.sessionID)
78
+ }
79
+ private func ordered(_ fixes: [CLLocation]) -> [TrackedLocation] {
80
+ let now = clock()
81
+ let offset = now.wall - now.elapsed
82
+ let previous = timebase
83
+ timebase = offset
84
+ var mapped: [TrackedLocation] = []
85
+ for fix in fixes {
86
+ var location = TrackedLocation(fix)
87
+ var elapsed = location.time - offset
88
+ if elapsed > now.elapsed + 1000 {
89
+ guard let previous, abs(previous - offset) >= 2000, location.time - previous <= now.elapsed + 1000 else { continue }
90
+ elapsed = location.time - previous
91
+ }
92
+ location.elapsed = min(elapsed, now.elapsed) > 0 ? min(elapsed, now.elapsed) : nil
93
+ mapped.append(location)
94
+ }
95
+ for index in mapped.indices.reversed().dropFirst() {
96
+ if let current = mapped[index].elapsed, let next = mapped[index + 1].elapsed, current > next { mapped[index].elapsed = next }
97
+ }
98
+ return mapped
99
+ }
100
+ func locationManager(_ source: CLLocationManager, didFailWithError failure: Error) {
101
+ guard source === manager, profile.enabled, (failure as? CLError)?.code != .locationUnknown else { return }
102
+ let session = profile.sessionID
103
+ LocationServices.shared.refresh { [weak self] in
104
+ guard let self, self.profile.enabled, self.profile.sessionID == session else { return }
105
+ self.authorizationChanged?()
106
+ if LocationServices.shared.enabled { self.error?(TrackingError((failure as? CLError)?.code == .denied ? "PERMISSION_DENIED" : "UNAVAILABLE", "Core Location route updates failed"), session) }
107
+ }
108
+ }
109
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationChanged?() }
110
+ }
111
+
112
+ extension TrackedLocation {
113
+ init(_ fix: CLLocation) {
114
+ self.init(latitude: fix.coordinate.latitude, longitude: fix.coordinate.longitude, accuracy: fix.horizontalAccuracy, altitude: fix.verticalAccuracy >= 0 ? fix.altitude : nil, altitudeAccuracy: fix.verticalAccuracy >= 0 ? fix.verticalAccuracy : nil, speed: fix.speed >= 0 ? fix.speed : nil, speedAccuracy: fix.speedAccuracy >= 0 ? fix.speedAccuracy : nil, heading: fix.course >= 0 ? fix.course : nil, headingAccuracy: fix.courseAccuracy >= 0 ? fix.courseAccuracy : nil, time: fix.timestamp.timeIntervalSince1970 * 1000, isMock: fix.sourceInformation?.isSimulatedBySoftware ?? false)
115
+ }
116
+ init(_ fix: CLLocation, receivedAt now: TrackingInstant) {
117
+ self.init(fix)
118
+ let mapped = now.elapsed - (now.wall - time)
119
+ elapsed = time <= now.wall + 1000 && min(mapped, now.elapsed) > 0 ? min(mapped, now.elapsed) : nil
120
+ }
121
+ var location: CLLocation {
122
+ CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), altitude: altitude ?? 0, horizontalAccuracy: accuracy, verticalAccuracy: altitudeAccuracy ?? -1, course: heading ?? -1, courseAccuracy: headingAccuracy ?? -1, speed: speed ?? -1, speedAccuracy: speedAccuracy ?? -1, timestamp: Date(timeIntervalSince1970: time / 1000))
123
+ }
124
+ }
@@ -0,0 +1,155 @@
1
+ import Foundation
2
+ import Capacitor
3
+
4
+ @objc(LocationTrackingPlugin)
5
+ public final class LocationTrackingPlugin: CAPPlugin, CAPBridgedPlugin {
6
+ public let identifier = "LocationTrackingPlugin"
7
+ public let jsName = "LocationTracking"
8
+ public let pluginMethods: [CAPPluginMethod] = [
9
+ CAPPluginMethod(name: "ready", returnType: CAPPluginReturnPromise),
10
+ CAPPluginMethod(name: "setConfig", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "reset", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "startGeofences", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "changePace", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "getCurrentPosition", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "clearWatch", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "getOdometer", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "setOdometer", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "resetOdometer", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "getLocations", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "getCount", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "insertLocation", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "destroyLocations", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "destroyLocation", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "addGeofence", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "addGeofences", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "removeGeofence", returnType: CAPPluginReturnPromise),
31
+ CAPPluginMethod(name: "removeGeofences", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "getGeofences", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "getGeofence", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "geofenceExists", returnType: CAPPluginReturnPromise),
35
+ CAPPluginMethod(name: "getHeartbeatStatus", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "getProviderState", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "isPowerSaveMode", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "getBatteryOptimizationStatus", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "openBatteryOptimizationSettings", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "getPowerManagerInfo", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "openPowerManagerSettings", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "openLocationSettings", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "openAppSettings", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "getDeviceInfo", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "getSensors", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
47
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
48
+ CAPPluginMethod(name: "log", returnType: CAPPluginReturnPromise),
49
+ CAPPluginMethod(name: "getLog", returnType: CAPPluginReturnPromise),
50
+ CAPPluginMethod(name: "destroyLog", returnType: CAPPluginReturnPromise),
51
+ CAPPluginMethod(name: "uploadLog", returnType: CAPPluginReturnPromise),
52
+ CAPPluginMethod(name: "emailLog", returnType: CAPPluginReturnPromise),
53
+ CAPPluginMethod(name: "watchPosition", returnType: CAPPluginReturnCallback),
54
+ ]
55
+ private var subscription: NativeSubscription?
56
+ private var watches: [String: CAPPluginCall] = [:]
57
+ public override func load() {
58
+ subscription = LocationTrackingNative.addListener(PluginEventForwarder(self))
59
+ LocationTrackingNative.bootstrap(registerBackgroundTasks: false)
60
+ }
61
+ deinit {
62
+ subscription?.remove()
63
+ for id in watches.keys { LocationTrackingNative.call("clearWatch", options: ["id": id]) { _ in } }
64
+ }
65
+ public func onEvent(_ name: String, payload: TrackingJSON) {
66
+ DispatchQueue.main.async { [weak self] in self?.notifyListeners(name, data: payload) }
67
+ }
68
+ private func invoke(_ method: String, _ call: CAPPluginCall) {
69
+ LocationTrackingNative.perform(method, options: call.options as? TrackingJSON ?? [:], native: false) { result in
70
+ DispatchQueue.main.async {
71
+ switch result {
72
+ case .success(let value): call.resolve(value)
73
+ case .failure(let error): call.reject(error.localizedDescription, (error as? TrackingError)?.code ?? "INTERNAL")
74
+ }
75
+ }
76
+ }
77
+ }
78
+ @objc public func watchPosition(_ call: CAPPluginCall) {
79
+ call.keepAlive = true
80
+ DispatchQueue.main.async { self.beginWatch(call) }
81
+ }
82
+ private func beginWatch(_ call: CAPPluginCall) {
83
+ guard let id = call.callbackId else { call.reject("Missing callback id", "INTERNAL"); return }
84
+ watches[id] = call
85
+ var options: TrackingJSON = call.options as? TrackingJSON ?? [:]
86
+ options["_watchId"] = id
87
+ LocationTrackingNative.perform("watchPosition", options: options, native: false) { [weak self] result in
88
+ DispatchQueue.main.async {
89
+ guard let self, self.watches[id] != nil else { return }
90
+ switch result {
91
+ case .success(let wire): call.resolve(wire)
92
+ case .failure(let error): call.reject(error.localizedDescription, (error as? TrackingError)?.code ?? "INTERNAL")
93
+ }
94
+ }
95
+ }
96
+ }
97
+ @objc public func ready(_ call: CAPPluginCall) { invoke("ready", call) }
98
+ @objc public func setConfig(_ call: CAPPluginCall) { invoke("setConfig", call) }
99
+ @objc public func reset(_ call: CAPPluginCall) { invoke("reset", call) }
100
+ @objc public func getState(_ call: CAPPluginCall) { invoke("getState", call) }
101
+ @objc public func start(_ call: CAPPluginCall) { invoke("start", call) }
102
+ @objc public func startGeofences(_ call: CAPPluginCall) { invoke("startGeofences", call) }
103
+ @objc public func stop(_ call: CAPPluginCall) { invoke("stop", call) }
104
+ @objc public func changePace(_ call: CAPPluginCall) { invoke("changePace", call) }
105
+ @objc public func getCurrentPosition(_ call: CAPPluginCall) { invoke("getCurrentPosition", call) }
106
+ @objc public func clearWatch(_ call: CAPPluginCall) {
107
+ DispatchQueue.main.async {
108
+ if let id = call.getString("id"), let watch = self.watches.removeValue(forKey: id) {
109
+ watch.keepAlive = false
110
+ self.bridge?.releaseCall(watch)
111
+ }
112
+ self.invoke("clearWatch", call)
113
+ }
114
+ }
115
+ @objc public func getOdometer(_ call: CAPPluginCall) { invoke("getOdometer", call) }
116
+ @objc public func setOdometer(_ call: CAPPluginCall) { invoke("setOdometer", call) }
117
+ @objc public func resetOdometer(_ call: CAPPluginCall) { invoke("resetOdometer", call) }
118
+ @objc public func getLocations(_ call: CAPPluginCall) { invoke("getLocations", call) }
119
+ @objc public func getCount(_ call: CAPPluginCall) { invoke("getCount", call) }
120
+ @objc public func insertLocation(_ call: CAPPluginCall) { invoke("insertLocation", call) }
121
+ @objc public func destroyLocations(_ call: CAPPluginCall) { invoke("destroyLocations", call) }
122
+ @objc public func destroyLocation(_ call: CAPPluginCall) { invoke("destroyLocation", call) }
123
+ @objc public func sync(_ call: CAPPluginCall) { invoke("sync", call) }
124
+ @objc public func addGeofence(_ call: CAPPluginCall) { invoke("addGeofence", call) }
125
+ @objc public func addGeofences(_ call: CAPPluginCall) { invoke("addGeofences", call) }
126
+ @objc public func removeGeofence(_ call: CAPPluginCall) { invoke("removeGeofence", call) }
127
+ @objc public func removeGeofences(_ call: CAPPluginCall) { invoke("removeGeofences", call) }
128
+ @objc public func getGeofences(_ call: CAPPluginCall) { invoke("getGeofences", call) }
129
+ @objc public func getGeofence(_ call: CAPPluginCall) { invoke("getGeofence", call) }
130
+ @objc public func geofenceExists(_ call: CAPPluginCall) { invoke("geofenceExists", call) }
131
+ @objc public func getHeartbeatStatus(_ call: CAPPluginCall) { invoke("getHeartbeatStatus", call) }
132
+ @objc public func getProviderState(_ call: CAPPluginCall) { invoke("getProviderState", call) }
133
+ @objc public func isPowerSaveMode(_ call: CAPPluginCall) { invoke("isPowerSaveMode", call) }
134
+ @objc public func getBatteryOptimizationStatus(_ call: CAPPluginCall) { invoke("getBatteryOptimizationStatus", call) }
135
+ @objc public func openBatteryOptimizationSettings(_ call: CAPPluginCall) { invoke("openBatteryOptimizationSettings", call) }
136
+ @objc public func getPowerManagerInfo(_ call: CAPPluginCall) { invoke("getPowerManagerInfo", call) }
137
+ @objc public func openPowerManagerSettings(_ call: CAPPluginCall) { invoke("openPowerManagerSettings", call) }
138
+ @objc public func openLocationSettings(_ call: CAPPluginCall) { invoke("openLocationSettings", call) }
139
+ @objc public func openAppSettings(_ call: CAPPluginCall) { invoke("openAppSettings", call) }
140
+ @objc public func getDeviceInfo(_ call: CAPPluginCall) { invoke("getDeviceInfo", call) }
141
+ @objc public func getSensors(_ call: CAPPluginCall) { invoke("getSensors", call) }
142
+ @objc public override func checkPermissions(_ call: CAPPluginCall) { invoke("checkPermissions", call) }
143
+ @objc public override func requestPermissions(_ call: CAPPluginCall) { invoke("requestPermissions", call) }
144
+ @objc public func log(_ call: CAPPluginCall) { invoke("log", call) }
145
+ @objc public func getLog(_ call: CAPPluginCall) { invoke("getLog", call) }
146
+ @objc public func destroyLog(_ call: CAPPluginCall) { invoke("destroyLog", call) }
147
+ @objc public func uploadLog(_ call: CAPPluginCall) { invoke("uploadLog", call) }
148
+ @objc public func emailLog(_ call: CAPPluginCall) { invoke("emailLog", call) }
149
+ }
150
+
151
+ private final class PluginEventForwarder: LocationTrackingListener {
152
+ weak var plugin: LocationTrackingPlugin?
153
+ init(_ plugin: LocationTrackingPlugin) { self.plugin = plugin }
154
+ func onEvent(_ name: String, payload: TrackingJSON) { plugin?.onEvent(name, payload: payload) }
155
+ }