@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.
- package/BricksSoftCapacitorLocationTracking.podspec +18 -0
- package/Package.swift +13 -0
- package/README.md +476 -147
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
- package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
- package/android/src/main/res/xml/lt_file_paths.xml +1 -1
- package/dist/esm/definitions.d.ts +117 -8
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/events.d.ts +8 -2
- package/dist/esm/events.js +7 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web/config.js +10 -0
- package/dist/esm/web/config.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +8 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/esm/web.d.ts +0 -8
- package/dist/esm/web.js +12 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +39 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +39 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
- package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
- package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
- package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
- package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
- package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
- package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
- package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
- package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
- package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
- package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
- package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
- package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
- package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
- package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
- 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
|
+
}
|