@bricks-soft/capacitor-location-tracking 8.0.1 → 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 (124) hide show
  1. package/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +476 -147
  4. package/android/build.gradle +3 -1
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  7. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  15. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  17. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  18. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  19. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  20. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  23. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  26. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  33. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  34. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  37. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  39. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  40. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  42. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  43. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  44. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  51. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  53. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  54. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  55. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  56. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  57. package/dist/esm/definitions.d.ts +117 -8
  58. package/dist/esm/definitions.js.map +1 -1
  59. package/dist/esm/events.d.ts +8 -2
  60. package/dist/esm/events.js +7 -1
  61. package/dist/esm/events.js.map +1 -1
  62. package/dist/esm/index.d.ts +1 -1
  63. package/dist/esm/index.js +1 -1
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/web/config.js +10 -0
  66. package/dist/esm/web/config.js.map +1 -1
  67. package/dist/esm/web/device.d.ts +1 -1
  68. package/dist/esm/web/device.js +8 -1
  69. package/dist/esm/web/device.js.map +1 -1
  70. package/dist/esm/web.d.ts +0 -8
  71. package/dist/esm/web.js +12 -3
  72. package/dist/esm/web.js.map +1 -1
  73. package/dist/plugin.cjs.js +39 -5
  74. package/dist/plugin.cjs.js.map +1 -1
  75. package/dist/plugin.js +39 -5
  76. package/dist/plugin.js.map +1 -1
  77. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  78. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  79. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  80. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  81. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  82. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  83. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  84. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  85. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  86. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  87. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  88. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  89. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  90. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  91. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  92. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  93. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  94. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  95. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  96. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  97. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  98. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  99. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  100. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  101. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  102. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  103. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  104. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  105. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  106. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  107. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  108. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  109. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  110. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  111. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  112. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  113. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  114. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  115. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  116. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  117. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  118. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  119. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  120. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  121. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  122. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  123. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  124. package/package.json +10 -4
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import UIKit
3
+ import MessageUI
4
+ import zlib
5
+
6
+ final class LogExport: NSObject, MFMailComposeViewControllerDelegate {
7
+ private let transport = HTTPTransport()
8
+ private var mailer: MFMailComposeViewController?
9
+ static func gzip(_ data: Data) throws -> Data {
10
+ var stream = z_stream()
11
+ guard deflateInit2_(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 31, 8, Z_DEFAULT_STRATEGY, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else { throw TrackingError("IO_ERROR", "Cannot initialize log compression") }
12
+ defer { deflateEnd(&stream) }
13
+ var output = Data(count: Int(compressBound(uLong(data.count))) + 32)
14
+ let status = data.withUnsafeBytes { input in
15
+ output.withUnsafeMutableBytes { buffer in
16
+ stream.next_in = UnsafeMutablePointer(mutating: input.baseAddress?.assumingMemoryBound(to: Bytef.self))
17
+ stream.avail_in = uInt(data.count)
18
+ stream.next_out = buffer.baseAddress?.assumingMemoryBound(to: Bytef.self)
19
+ stream.avail_out = uInt(buffer.count)
20
+ return deflate(&stream, Z_FINISH)
21
+ }
22
+ }
23
+ guard status == Z_STREAM_END else { throw TrackingError("IO_ERROR", "Cannot compress logs") }
24
+ output.count = Int(stream.total_out)
25
+ return output
26
+ }
27
+ func upload(_ text: String, options: TrackingJSON, completion: @escaping (Result<TrackingJSON, Error>) -> Void) throws {
28
+ let address = try options.requiringString("url")
29
+ guard let url = URL(string: address), ["https", "http"].contains(url.scheme ?? ""), url.host != nil else { throw TrackingError("INVALID_ARGUMENT", "url must be HTTP or HTTPS") }
30
+ let boundary = UUID().uuidString
31
+ var body = Data()
32
+ func append(_ text: String) { body.append(Data(text.utf8)) }
33
+ append("--\(boundary)\r\nContent-Disposition: form-data; name=\"log\"; filename=\"location-tracking.log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
34
+ body.append(try Self.gzip(Data(text.utf8)))
35
+ append("\r\n")
36
+ for (key, value) in options.object("params") {
37
+ guard !key.contains("\r"), !key.contains("\n"), !key.contains("\"") else { throw TrackingError("INVALID_ARGUMENT", "Invalid multipart field name") }
38
+ let text: String
39
+ if let string = value as? String { text = string }
40
+ else { text = String(decoding: try jsonData(value), as: UTF8.self) }
41
+ append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(text)\r\n")
42
+ }
43
+ append("--\(boundary)--\r\n")
44
+ var headers = options["headers"] as? [String: String] ?? [:]
45
+ headers["Content-Type"] = "multipart/form-data; boundary=\(boundary)"
46
+ transport.send(url: url, method: "POST", headers: headers, body: body, timeout: 60000) { result in
47
+ switch result {
48
+ case .success(let response): completion(.success(["success": response.success, "status": response.status]))
49
+ case .failure: completion(.success(["success": false, "status": 0]))
50
+ }
51
+ }
52
+ }
53
+ func email(_ text: String, options: TrackingJSON, completion: @escaping (Result<TrackingJSON, Error>) -> Void) {
54
+ do {
55
+ let email = try options.requiringString("email")
56
+ guard MFMailComposeViewController.canSendMail() else { throw TrackingError("UNAVAILABLE", "Mail is not configured on this device") }
57
+ guard mailer == nil else { throw TrackingError("UNAVAILABLE", "Log email is already open") }
58
+ guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first(where: { $0.activationState == .foregroundActive }), var presenter = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { throw TrackingError("NO_ACTIVITY", "No active app window") }
59
+ while let presented = presenter.presentedViewController { presenter = presented }
60
+ let controller = MFMailComposeViewController()
61
+ controller.mailComposeDelegate = self
62
+ controller.setToRecipients([email])
63
+ controller.setSubject(options.text("subject", "Location tracking log"))
64
+ controller.addAttachmentData(try Self.gzip(Data(text.utf8)), mimeType: "application/gzip", fileName: "location-tracking.log.gz")
65
+ mailer = controller
66
+ presenter.present(controller, animated: true) { completion(.success([:])) }
67
+ } catch { completion(.failure(error)) }
68
+ }
69
+ func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
70
+ controller.dismiss(animated: true)
71
+ mailer = nil
72
+ }
73
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ import os
3
+
4
+ final class TrackingLog {
5
+ private let logger = Logger(subsystem: "com.brickssoft.locationtracking", category: "plugin")
6
+ private var entries: [TrackingJSON] = []
7
+ private let store: TrackingStore
8
+ private var minimumLevel = "info"
9
+ private var maximumDays = 3
10
+ private let severity = ["off": Int.max, "error": 5, "warn": 4, "info": 3, "debug": 2, "verbose": 1]
11
+ init(store: TrackingStore) {
12
+ self.store = store
13
+ entries = (try? store.load("logs"))?["entries"] as? [TrackingJSON] ?? []
14
+ }
15
+ func configure(_ config: TrackingJSON) {
16
+ minimumLevel = config.text("logLevel", "info")
17
+ maximumDays = max(1, Int(config.number("logMaxDays", 3)))
18
+ prune()
19
+ }
20
+ private func prune() {
21
+ let cutoff = Date().addingTimeInterval(-Double(maximumDays) * 86400).timeIntervalSince1970 * 1000
22
+ entries.removeAll { $0.number("at") < cutoff }
23
+ }
24
+ func write(_ level: String, _ message: String) {
25
+ guard (severity[level] ?? 0) >= (severity[minimumLevel] ?? 3) else { return }
26
+ prune()
27
+ let safe = Self.redact(message)
28
+ entries.append(["at": Date().timeIntervalSince1970 * 1000, "level": level, "text": "\(iso()) \(level) \(safe)"])
29
+ if entries.count > 2000 { entries.removeFirst(entries.count - 2000) }
30
+ do { try store.save("logs", ["entries": entries]) }
31
+ catch { logger.error("Log persistence failed") }
32
+ logger.log("\(safe, privacy: .private)")
33
+ }
34
+ func read(_ query: TrackingJSON) -> String {
35
+ prune()
36
+ var values = entries.filter { $0.number("at") >= query.number("start", 0) && $0.number("at") <= query.number("end", .greatestFiniteMagnitude) && (query.text("level").isEmpty || (severity[$0.text("level")] ?? 0) >= (severity[query.text("level")] ?? 0)) }
37
+ if query.text("order", "asc") == "desc" { values.reverse() }
38
+ return values.prefix(max(0, Int(query.number("limit", 2000)))).map { $0.text("text") }.joined(separator: "\n")
39
+ }
40
+ func clear() throws { try store.save("logs", ["entries": []]); entries = [] }
41
+ private static let redactions: [NSRegularExpression] = [#"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"#, #"(?i)\b(?:https?|wss?)://[^\s"'<>]+"#, #"\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*"#, #"(?i)("?\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|secret|client[_-]?secret|api[_-]?key|authorization)\b"?\s*[:=]\s*)("(?:[^"\\]|\\.)*"|[^\s,;&}]+)"#].compactMap { try? NSRegularExpression(pattern: $0) }
42
+ static func redact(_ text: String) -> String {
43
+ var result = text
44
+ for regex in Self.redactions { result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "[redacted]") }
45
+ return result
46
+ }
47
+ }
@@ -0,0 +1,32 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ final class LocationServices {
5
+ static let shared = LocationServices()
6
+ private(set) var enabled = true
7
+ private(set) var isReady = false
8
+ private var refreshing = false
9
+ private var completions: [() -> Void] = []
10
+ private let read: () -> Bool
11
+ init(read: @escaping () -> Bool = { CLLocationManager.locationServicesEnabled() }) { self.read = read }
12
+ func whenReady(_ completion: @escaping () -> Void) {
13
+ if isReady { completion() }
14
+ else { refresh(completion: completion) }
15
+ }
16
+ func refresh(completion: (() -> Void)? = nil) {
17
+ if let completion { completions.append(completion) }
18
+ guard !refreshing else { return }
19
+ refreshing = true
20
+ DispatchQueue.global(qos: .utility).async {
21
+ let enabled = self.read()
22
+ DispatchQueue.main.async {
23
+ self.enabled = enabled
24
+ self.isReady = true
25
+ self.refreshing = false
26
+ let waiting = self.completions
27
+ self.completions = []
28
+ waiting.forEach { $0() }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,102 @@
1
+ import Foundation
2
+ import CoreLocation
3
+ import CoreMotion
4
+ import UserNotifications
5
+ import UIKit
6
+
7
+ final class Permissions: NSObject, CLLocationManagerDelegate {
8
+ static let order = ["location", "notifications", "activityRecognition", "backgroundLocation"]
9
+ let manager: CLLocationManager
10
+ private let motion = CMMotionActivityManager()
11
+ private var requests: [([String], (TrackingJSON) -> Void)] = []
12
+ private var authorizationDone: (() -> Void)?
13
+ private var awaitingAlways = false
14
+ private var activeObserver: NSObjectProtocol?
15
+ var providerChanged: ((TrackingJSON) -> Void)?
16
+ init(manager: CLLocationManager = CLLocationManager()) {
17
+ self.manager = manager
18
+ super.init()
19
+ manager.delegate = self
20
+ LocationServices.shared.refresh()
21
+ activeObserver = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
22
+ LocationServices.shared.refresh()
23
+ guard let self, self.awaitingAlways else { return }
24
+ self.finishAuthorization()
25
+ }
26
+ }
27
+ deinit { if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) } }
28
+ var provider: TrackingJSON {
29
+ let status = manager.authorizationStatus
30
+ let authorized = status == .authorizedAlways || status == .authorizedWhenInUse
31
+ let enabled = LocationServices.shared.enabled
32
+ return ["enabled": enabled, "gps": enabled, "network": enabled, "permission": status == .authorizedAlways ? "always" : authorized ? "when_in_use" : "denied", "accuracy": authorized ? (manager.accuracyAuthorization == .fullAccuracy ? "precise" : "approximate") : "none", "backend": PluginInfo.backend]
33
+ }
34
+ func status(_ completion: @escaping (TrackingJSON) -> Void) {
35
+ let location: String
36
+ switch manager.authorizationStatus {
37
+ case .notDetermined: location = "prompt"
38
+ case .authorizedAlways, .authorizedWhenInUse: location = "granted"
39
+ default: location = "denied"
40
+ }
41
+ let activity: String
42
+ switch CMMotionActivityManager.authorizationStatus() {
43
+ case .notDetermined: activity = "prompt"
44
+ case .authorized: activity = "granted"
45
+ default: activity = "denied"
46
+ }
47
+ let background = manager.authorizationStatus == .authorizedAlways ? "granted" : manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted ? "denied" : "prompt"
48
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
49
+ let notification = settings.authorizationStatus == .notDetermined ? "prompt" : settings.authorizationStatus == .denied ? "denied" : "granted"
50
+ DispatchQueue.main.async { completion(["location": location, "backgroundLocation": background, "activityRecognition": activity, "notifications": notification]) }
51
+ }
52
+ }
53
+ func request(_ types: [String], completion: @escaping (TrackingJSON) -> Void) {
54
+ requests.append((types, completion))
55
+ if requests.count == 1 { advance(types) }
56
+ }
57
+ private func advance(_ types: [String]) {
58
+ guard let first = types.first else {
59
+ status { result in
60
+ let callback = self.requests.removeFirst().1
61
+ callback(result)
62
+ if let next = self.requests.first { self.advance(next.0) }
63
+ }
64
+ return
65
+ }
66
+ let rest = Array(types.dropFirst())
67
+ let next = { self.advance(rest) }
68
+ switch first {
69
+ case "location" where manager.authorizationStatus == .notDetermined:
70
+ authorizationDone = next
71
+ manager.requestWhenInUseAuthorization()
72
+ case "backgroundLocation" where (manager.authorizationStatus == .notDetermined || manager.authorizationStatus == .authorizedWhenInUse) && !UserDefaults.standard.bool(forKey: "LT.alwaysAsked"):
73
+ UserDefaults.standard.set(true, forKey: "LT.alwaysAsked")
74
+ awaitingAlways = true
75
+ authorizationDone = next
76
+ manager.requestAlwaysAuthorization()
77
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
78
+ if self.awaitingAlways && UIApplication.shared.applicationState == .active { self.finishAuthorization() }
79
+ }
80
+ case "activityRecognition" where CMMotionActivityManager.isActivityAvailable() && CMMotionActivityManager.authorizationStatus() == .notDetermined:
81
+ motion.queryActivityStarting(from: Date().addingTimeInterval(-1), to: Date(), to: .main) { _, _ in next() }
82
+ case "notifications":
83
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in DispatchQueue.main.async(execute: next) }
84
+ default: next()
85
+ }
86
+ }
87
+ private func finishAuthorization() {
88
+ let done = authorizationDone
89
+ authorizationDone = nil
90
+ awaitingAlways = false
91
+ done?()
92
+ }
93
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
94
+ LocationServices.shared.refresh { [weak self] in
95
+ guard let self else { return }
96
+ self.providerChanged?(self.provider)
97
+ }
98
+ if awaitingAlways {
99
+ if manager.authorizationStatus == .authorizedAlways { finishAuthorization() }
100
+ } else if manager.authorizationStatus != .notDetermined { finishAuthorization() }
101
+ }
102
+ }
@@ -0,0 +1,79 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ final class PositionSource: NSObject, CLLocationManagerDelegate {
5
+ private let manager = CLLocationManager()
6
+ private let services: LocationServices
7
+ private let callback: (Result<CLLocation, Error>) -> Void
8
+ private let collector: SampleCollector
9
+ private let rejectMock: Bool
10
+ private let requestedAt = Date()
11
+ private let continuous: Bool
12
+ private let interval: TimeInterval
13
+ private var stopped = false
14
+ private var timeout: DispatchWorkItem?
15
+ private var deliveredAt: Double?
16
+ init(options: TrackingJSON, continuous: Bool, services: LocationServices = .shared, callback: @escaping (Result<CLLocation, Error>) -> Void) {
17
+ self.services = services
18
+ self.callback = callback
19
+ self.collector = SampleCollector(Int(options.number("samples", 3)))
20
+ self.rejectMock = options.flag("_rejectMock")
21
+ self.continuous = continuous
22
+ self.interval = max(0, options.number("interval", 1000)) / 1000
23
+ super.init()
24
+ manager.delegate = self
25
+ manager.desiredAccuracy = RouteSource.accuracy(options.text("desiredAccuracy", "high"))
26
+ manager.distanceFilter = kCLDistanceFilterNone
27
+ manager.pausesLocationUpdatesAutomatically = false
28
+ let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] ?? []
29
+ manager.allowsBackgroundLocationUpdates = continuous && modes.contains("location")
30
+ manager.showsBackgroundLocationIndicator = continuous
31
+ if !continuous {
32
+ let item = DispatchWorkItem { [weak self] in
33
+ guard let self, !self.stopped else { return }
34
+ if let best = self.collector.best { self.callback(.success(best)) }
35
+ else { self.callback(.failure(TrackingError("TIMEOUT", "Position request timed out"))) }
36
+ self.stop()
37
+ }
38
+ timeout = item
39
+ DispatchQueue.main.asyncAfter(deadline: .now() + options.number("timeout", 30000) / 1000, execute: item)
40
+ }
41
+ }
42
+ func start() {
43
+ guard continuous || services.enabled else { callback(.failure(TrackingError("LOCATION_DISABLED", "Location Services are disabled"))); stop(); return }
44
+ guard !services.enabled || manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else { callback(.failure(TrackingError("PERMISSION_DENIED", "Location permission is required"))); stop(); return }
45
+ manager.startUpdatingLocation()
46
+ }
47
+ func stop() { stopped = true; timeout?.cancel(); manager.stopUpdatingLocation(); manager.delegate = nil }
48
+ func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
49
+ guard !stopped else { return }
50
+ for fix in locations where fix.horizontalAccuracy >= 0 && CLLocationCoordinate2DIsValid(fix.coordinate) {
51
+ if !continuous && rejectMock && fix.sourceInformation?.isSimulatedBySoftware == true { continue }
52
+ if continuous {
53
+ let now = TrackingClock.elapsedMilliseconds()
54
+ if deliveredAt == nil || now - deliveredAt! >= interval * 1000 { deliveredAt = now; callback(.success(fix)) }
55
+ } else {
56
+ if fix.timestamp < requestedAt.addingTimeInterval(-2) { continue }
57
+ if collector.add(fix) { callback(.success(collector.best!)); stop(); return }
58
+ }
59
+ }
60
+ }
61
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
62
+ if continuous && !stopped && (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse) { manager.startUpdatingLocation() }
63
+ }
64
+
65
+ func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
66
+ if (error as? CLError)?.code == .locationUnknown { return }
67
+ if continuous {
68
+ services.refresh { [weak self] in
69
+ guard let self, self.services.enabled else { return }
70
+ self.fail(error)
71
+ }
72
+ } else { fail(error) }
73
+ }
74
+ private func fail(_ error: Error) {
75
+ guard !stopped else { return }
76
+ callback(.failure(TrackingError((error as? CLError)?.code == .denied ? "PERMISSION_DENIED" : "UNAVAILABLE", "Core Location could not obtain a position")))
77
+ stop()
78
+ }
79
+ }
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ final class Positions {
5
+ private let services: LocationServices
6
+ private var pending = Set<String>()
7
+ init(services: LocationServices = .shared) { self.services = services }
8
+ private var sources: [String: PositionSource] = [:]
9
+ private var cached: CachedPosition?
10
+ private let manager = CLLocationManager()
11
+ func request(id: String, options: TrackingJSON, watch: Bool, completion: @escaping (Result<CLLocation, Error>) -> Void) {
12
+ guard services.isReady else {
13
+ pending.insert(id)
14
+ services.whenReady {
15
+ guard self.pending.remove(id) != nil else { return }
16
+ self.request(id: id, options: options, watch: watch, completion: completion)
17
+ }
18
+ return
19
+ }
20
+ if !watch && !services.enabled { completion(.failure(TrackingError("LOCATION_DISABLED", "Location Services are disabled"))); return }
21
+ if services.enabled && manager.authorizationStatus != .authorizedAlways && manager.authorizationStatus != .authorizedWhenInUse { completion(.failure(TrackingError("PERMISSION_DENIED", "Location permission is required"))); return }
22
+ if !watch, options.number("maximumAge") > 0, let cached, cached.age() <= options.number("maximumAge"), !(options.flag("_rejectMock") && cached.fix.sourceInformation?.isSimulatedBySoftware == true) { completion(.success(cached.fix)); return }
23
+ let source = PositionSource(options: options, continuous: watch, services: services) { [weak self] result in
24
+ if case .success(let fix) = result { self?.cached = CachedPosition(fix) }
25
+ if !watch { self?.sources.removeValue(forKey: id) }
26
+ else if case .failure = result { self?.sources.removeValue(forKey: id) }
27
+ completion(result)
28
+ }
29
+ sources[id] = source
30
+ source.start()
31
+ }
32
+ func clear(_ id: String) { pending.remove(id); sources.removeValue(forKey: id)?.stop() }
33
+ }
@@ -0,0 +1,31 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ final class SampleCollector {
5
+ private let wanted: Int
6
+ private(set) var best: CLLocation?
7
+ private(set) var count = 0
8
+ init(_ wanted: Int) { self.wanted = max(1, wanted) }
9
+ func add(_ fix: CLLocation) -> Bool {
10
+ count += 1
11
+ if let current = best {
12
+ if rank(fix) < rank(current) || (rank(fix) == rank(current) && fix.timestamp >= current.timestamp) { best = fix }
13
+ } else { best = fix }
14
+ return count >= wanted || best.map { rank($0) <= 10 } == true
15
+ }
16
+ private func rank(_ fix: CLLocation) -> Double { fix.horizontalAccuracy > 0 ? fix.horizontalAccuracy : .infinity }
17
+ }
18
+
19
+ struct CachedPosition {
20
+ let fix: CLLocation
21
+ let receivedElapsed: Double
22
+ let ageAtReceipt: Double
23
+ init(_ fix: CLLocation, wall: Date = Date(), elapsed: Double = TrackingClock.elapsedMilliseconds()) {
24
+ self.fix = fix
25
+ receivedElapsed = elapsed
26
+ ageAtReceipt = max(0, wall.timeIntervalSince(fix.timestamp) * 1000)
27
+ }
28
+ func age(wall: Date = Date(), elapsed: Double = TrackingClock.elapsedMilliseconds()) -> Double {
29
+ max(wall.timeIntervalSince(fix.timestamp) * 1000, ageAtReceipt + max(0, elapsed - receivedElapsed))
30
+ }
31
+ }
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0"><dict>
4
+ <key>NSPrivacyTracking</key><false/>
5
+ <key>NSPrivacyTrackingDomains</key><array/>
6
+ <key>NSPrivacyCollectedDataTypes</key><array/>
7
+ <key>NSPrivacyAccessedAPITypes</key><array>
8
+ <dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string><key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
9
+ </array>
10
+ </dict></plist>
@@ -0,0 +1,83 @@
1
+ import Foundation
2
+
3
+ final class KalmanFilter {
4
+ private var last: TrackedLocation?
5
+ private var latitude = 0.0
6
+ private var longitude = 0.0
7
+ private var variance = 0.0
8
+ func filter(_ fix: TrackedLocation) -> TrackedLocation {
9
+ let accuracy = fix.accuracy.isFinite && fix.accuracy > 1 ? fix.accuracy : 1
10
+ let measurement = accuracy * accuracy
11
+ let dt = last.map { Geo.elapsed($0, fix) }
12
+ last = fix
13
+ guard let dt, dt >= 0, dt <= 60000 else {
14
+ latitude = fix.latitude; longitude = fix.longitude; variance = measurement
15
+ return fix
16
+ }
17
+ if dt > 0 {
18
+ let reported = fix.speed.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
19
+ let speed = reported.map { max(1, $0) } ?? 3
20
+ let seconds = dt / 1000
21
+ variance += speed * speed * seconds * max(1, seconds)
22
+ }
23
+ let gain = variance / (variance + measurement)
24
+ latitude += gain * (fix.latitude - latitude)
25
+ longitude = Geo.normalize(longitude + gain * Geo.wrap(fix.longitude - longitude))
26
+ variance *= 1 - gain
27
+ var result = fix; result.latitude = latitude; result.longitude = longitude
28
+ return result
29
+ }
30
+ func reset() { last = nil; variance = 0 }
31
+ }
32
+
33
+ enum FilterResult {
34
+ case accepted(TrackedLocation)
35
+ case rejected(String)
36
+ }
37
+
38
+ final class LocationProcessor {
39
+ private let lock = NSLock()
40
+ private let kalman = KalmanFilter()
41
+ private var lastAccepted: TrackedLocation?
42
+ func process(_ fix: TrackedLocation, filter: TrackingJSON) -> FilterResult {
43
+ lock.lock(); defer { lock.unlock() }
44
+ if !Geo.valid(fix.latitude, fix.longitude) || (fix.latitude == 0 && fix.longitude == 0) { return .rejected("invalid coordinates") }
45
+ if !(fix.accuracy <= filter.number("trackingAccuracyThreshold", 100)) { return .rejected("accuracy exceeds trackingAccuracyThreshold") }
46
+ if fix.isMock && filter.flag("rejectMockLocations") { return .rejected("mock location") }
47
+ if let previous = lastAccepted {
48
+ let dt = Geo.elapsed(previous, fix)
49
+ if dt < 0 { return .rejected("stale fix") }
50
+ if !filter.flag("allowIdenticalLocations") && fix.latitude == previous.latitude && fix.longitude == previous.longitude && fix.time == previous.time { return .rejected("identical fix") }
51
+ let maxSpeed = filter.number("maxImpliedSpeed", 80)
52
+ if maxSpeed > 0 && dt > 0 && Geo.distance(previous, fix) / (dt / 1000) > maxSpeed { return .rejected("implied speed exceeds maxImpliedSpeed") }
53
+ }
54
+ lastAccepted = fix
55
+ if filter.flag("useKalman") { return .accepted(kalman.filter(fix)) }
56
+ kalman.reset()
57
+ return .accepted(fix)
58
+ }
59
+ func reset() { lock.lock(); defer { lock.unlock() }; lastAccepted = nil; kalman.reset() }
60
+ }
61
+
62
+ final class Odometer {
63
+ private var anchor: TrackedLocation?
64
+ private var session: Double?
65
+ func onLocation(_ fix: TrackedLocation, runtime: TrackingJSON, threshold: Double) -> Double {
66
+ guard passes(fix, threshold) else { return 0 }
67
+ let started = runtime["trackingStartedAt"] as? Double
68
+ if session != started {
69
+ session = started
70
+ anchor = nil
71
+ if let started, runtime.flag("enabled"), let last = TrackedLocation.from(runtime.object("lastLocation")), Self.belongs(last, to: started, runtime: runtime), !fix.isOlder(than: last), passes(last, threshold) { anchor = last }
72
+ }
73
+ let previous = anchor
74
+ anchor = fix
75
+ return previous.map { Geo.distance($0, fix) } ?? 0
76
+ }
77
+ func reset(session: Double?) { anchor = nil; self.session = session }
78
+ static func belongs(_ last: TrackedLocation, to started: Double, runtime: TrackingJSON) -> Bool {
79
+ guard let session = last.session else { return last.time >= started }
80
+ return session == started
81
+ }
82
+ private func passes(_ fix: TrackedLocation, _ threshold: Double) -> Bool { fix.accuracy <= threshold && Geo.valid(fix.latitude, fix.longitude) }
83
+ }
@@ -0,0 +1,64 @@
1
+ import Foundation
2
+
3
+ struct TrackedLocation: Codable, Equatable {
4
+ var latitude: Double
5
+ var longitude: Double
6
+ var accuracy: Double = 5.2
7
+ var altitude: Double? = nil
8
+ var altitudeAccuracy: Double? = nil
9
+ var speed: Double? = nil
10
+ var speedAccuracy: Double? = nil
11
+ var heading: Double? = nil
12
+ var headingAccuracy: Double? = nil
13
+ var time: Double
14
+ var elapsed: Double? = nil
15
+ var isMock = false
16
+ var session: Double? = nil
17
+
18
+ var accuracyMeters: Double { accuracy.isFinite && accuracy > 0 ? accuracy : 0 }
19
+ func isOlder(than other: TrackedLocation) -> Bool { Geo.elapsed(self, other) > 0 }
20
+ var json: TrackingJSON { (try? jsonObject(JSONEncoder().encode(self))) ?? [:] }
21
+ static func from(_ json: TrackingJSON) -> TrackedLocation? { try? JSONDecoder().decode(Self.self, from: jsonData(json)) }
22
+ }
23
+
24
+ enum Geo {
25
+ static func valid(_ latitude: Double, _ longitude: Double) -> Bool {
26
+ latitude.isFinite && longitude.isFinite && (-90...90).contains(latitude) && (-180...180).contains(longitude)
27
+ }
28
+ static func distance(_ a: TrackedLocation, _ b: TrackedLocation) -> Double {
29
+ distance(a.latitude, a.longitude, b.latitude, b.longitude)
30
+ }
31
+ static func distance(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
32
+ let radians = Double.pi / 180
33
+ let latitude = sin((lat2 - lat1) * radians / 2)
34
+ let longitude = sin((lng2 - lng1) * radians / 2)
35
+ let h = latitude * latitude + cos(lat1 * radians) * cos(lat2 * radians) * longitude * longitude
36
+ return 6371008.8 * 2 * asin(sqrt(min(1, max(0, h))))
37
+ }
38
+ static func elapsed(_ a: TrackedLocation, _ b: TrackedLocation) -> Double {
39
+ if let first = a.elapsed, let second = b.elapsed, first > 0, second > 0 { return second - first }
40
+ return b.time - a.time
41
+ }
42
+ static func wrap(_ delta: Double) -> Double {
43
+ let reduced = (delta + 180).truncatingRemainder(dividingBy: 360)
44
+ return (reduced < 0 ? reduced + 360 : reduced) - 180
45
+ }
46
+ static func normalize(_ longitude: Double) -> Double {
47
+ if (-180...180).contains(longitude) { return longitude }
48
+ return wrap(longitude)
49
+ }
50
+ }
51
+
52
+ enum ElasticDistance {
53
+ static func threshold(_ distance: Double, _ speed: Double?, _ multiplier: Double, _ disabled: Bool) -> Double {
54
+ let base = distance.isFinite ? max(0, distance) : 0
55
+ if disabled { return base }
56
+ let velocity = speed.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } ?? 0
57
+ let factor = multiplier.isFinite ? multiplier : 1
58
+ return base * max(1, floor(velocity / 5 + 0.5) * factor)
59
+ }
60
+ static func shouldRecord(_ previous: TrackedLocation?, _ fix: TrackedLocation, _ config: TrackingJSON) -> Bool {
61
+ guard let previous else { return true }
62
+ return Geo.distance(previous, fix) >= threshold(config.number("distanceFilter", 10), fix.speed, config.number("elasticityMultiplier", 1), config.flag("disableElasticity"))
63
+ }
64
+ }
@@ -0,0 +1,54 @@
1
+ import Foundation
2
+ import CoreFoundation
3
+
4
+ struct ExternalRecord {
5
+ let location: TrackedLocation
6
+ let event: String
7
+ let moving: Bool?
8
+ let extras: TrackingJSON
9
+ static let events = Set(["location", "motionchange", "current_position", "watch_position", "heartbeat", "geofence", "tracking_start", "tracking_stop", "providerchange"])
10
+ init(_ input: TrackingJSON, now: Double) throws {
11
+ let coords = try input.requiringObject("coords")
12
+ guard let latitude = Self.number(coords["latitude"]) else { throw TrackingError("INVALID_ARGUMENT", "coords.latitude is required") }
13
+ guard let longitude = Self.number(coords["longitude"]) else { throw TrackingError("INVALID_ARGUMENT", "coords.longitude is required") }
14
+ guard Geo.valid(latitude, longitude) else { throw TrackingError("INVALID_ARGUMENT", "coords are out of range") }
15
+ guard let accuracy = Self.number(coords["accuracy"]) else { throw TrackingError("INVALID_ARGUMENT", "coords.accuracy is required") }
16
+ guard accuracy > 0 else { throw TrackingError("INVALID_ARGUMENT", "coords.accuracy must be > 0") }
17
+ var time = now
18
+ if let value = input["timestamp"], !(value is NSNull) {
19
+ let formatter = ISO8601DateFormatter()
20
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
21
+ guard let text = value as? String else { throw TrackingError("INVALID_ARGUMENT", "timestamp must be ISO-8601") }
22
+ var parsed = formatter.date(from: text)
23
+ if parsed == nil { formatter.formatOptions = [.withInternetDateTime]; parsed = formatter.date(from: text) }
24
+ guard let parsed else { throw TrackingError("INVALID_ARGUMENT", "timestamp must be ISO-8601") }
25
+ time = parsed.timeIntervalSince1970 * 1000
26
+ }
27
+ event = input["event"] == nil || input["event"] is NSNull ? "location" : input.text("event")
28
+ guard Self.events.contains(event) else { throw TrackingError("INVALID_ARGUMENT", "Unknown event") }
29
+ if let value = input["is_moving"], !(value is NSNull) {
30
+ if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { moving = number.boolValue }
31
+ else if let text = value as? String, ["true", "false"].contains(text.lowercased()) { moving = text.lowercased() == "true" }
32
+ else { throw TrackingError("INVALID_ARGUMENT", "is_moving must be a boolean") }
33
+ } else { moving = nil }
34
+ if let value = input["extras"], !(value is NSNull) {
35
+ guard let object = value as? TrackingJSON else { throw TrackingError("INVALID_ARGUMENT", "extras must be an object") }
36
+ extras = object
37
+ } else { extras = [:] }
38
+ func nonNegative(_ key: String) -> Double? { Self.number(coords[key]).flatMap { $0 >= 0 ? $0 : nil } }
39
+ location = TrackedLocation(latitude: latitude, longitude: longitude, accuracy: accuracy, altitude: Self.number(coords["altitude"]), altitudeAccuracy: nonNegative("altitude_accuracy"), speed: nonNegative("speed"), speedAccuracy: nonNegative("speed_accuracy"), heading: nonNegative("heading"), headingAccuracy: nonNegative("heading_accuracy"), time: time)
40
+ }
41
+ var coordinates: TrackingJSON {
42
+ ["latitude": location.latitude, "longitude": location.longitude, "accuracy": location.accuracy,
43
+ "altitude": location.altitude as Any? ?? NSNull(), "altitude_accuracy": location.altitudeAccuracy as Any? ?? NSNull(),
44
+ "speed": location.speed as Any? ?? NSNull(), "speed_accuracy": location.speedAccuracy as Any? ?? NSNull(),
45
+ "heading": location.heading as Any? ?? NSNull(), "heading_accuracy": location.headingAccuracy as Any? ?? NSNull()]
46
+ }
47
+ private static func number(_ value: Any?) -> Double? {
48
+ let parsed: Double?
49
+ if let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { parsed = number.doubleValue }
50
+ else if let text = value as? String { parsed = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
51
+ else { parsed = nil }
52
+ return parsed.flatMap { $0.isFinite ? $0 : nil }
53
+ }
54
+ }
@@ -0,0 +1,27 @@
1
+ import Foundation
2
+ import CoreLocation
3
+ import UIKit
4
+
5
+ struct RecordFactory {
6
+ static func make(event: String, fix: CLLocation?, extras: TrackingJSON, odometer: Double, provider: TrackingJSON, network: String) -> TrackingJSON {
7
+ var coords: Any = NSNull()
8
+ if let fix {
9
+ coords = ["latitude": fix.coordinate.latitude, "longitude": fix.coordinate.longitude, "accuracy": fix.horizontalAccuracy,
10
+ "altitude": fix.verticalAccuracy >= 0 ? fix.altitude as Any : NSNull(), "altitude_accuracy": fix.verticalAccuracy >= 0 ? fix.verticalAccuracy as Any : NSNull(),
11
+ "speed": fix.speed >= 0 ? fix.speed as Any : NSNull(), "speed_accuracy": fix.speedAccuracy >= 0 ? fix.speedAccuracy as Any : NSNull(),
12
+ "heading": fix.course >= 0 ? fix.course as Any : NSNull(), "heading_accuracy": fix.courseAccuracy >= 0 ? fix.courseAccuracy as Any : NSNull()]
13
+ }
14
+ let readings = DeviceReadings.shared.snapshot(event: event)
15
+ var wire: TrackingJSON = ["uuid": UUID().uuidString.lowercased(), "event": event, "timestamp": fix.map { iso($0.timestamp) } as Any? ?? NSNull(), "recorded_at": iso(),
16
+ "elapsed_realtime_ms": continuousMilliseconds(), "boot_count": -1, "is_moving": false, "odometer": odometer,
17
+ "mock": fix?.sourceInformation?.isSimulatedBySoftware ?? false, "coords": coords, "activity": ["type": "unknown", "confidence": 0],
18
+ "battery": readings.battery,
19
+ "backend": PluginInfo.backend, "extras": extras, "provider": provider, "network": network, "device": readings.device]
20
+ if extras.isEmpty { wire.removeValue(forKey: "extras") }
21
+ if readings.device.isEmpty { wire.removeValue(forKey: "device") }
22
+ return wire
23
+ }
24
+ static func continuousMilliseconds() -> Double {
25
+ TrackingClock.elapsedMilliseconds()
26
+ }
27
+ }