@bricks-soft/capacitor-location-tracking 8.1.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BricksSoftCapacitorLocationTracking.podspec +18 -0
- package/Package.swift +13 -0
- package/README.md +107 -29
- package/android/build.gradle +3 -1
- package/dist/esm/definitions.d.ts +10 -4
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +1 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/plugin.cjs.js +1 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +1 -1
- 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 +9 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum ConfigDefaults {
|
|
4
|
+
static let value: TrackingJSON = try! jsonObject(Data(#"""
|
|
5
|
+
{
|
|
6
|
+
"geolocation": {
|
|
7
|
+
"desiredAccuracy": "high",
|
|
8
|
+
"distanceFilter": 10,
|
|
9
|
+
"locationUpdateInterval": 1000,
|
|
10
|
+
"fastestLocationUpdateInterval": 500,
|
|
11
|
+
"disableElasticity": false,
|
|
12
|
+
"elasticityMultiplier": 1,
|
|
13
|
+
"stationaryRadius": 25,
|
|
14
|
+
"stopTimeout": 5,
|
|
15
|
+
"stopAfterElapsedMinutes": 0,
|
|
16
|
+
"stopOnStationary": false,
|
|
17
|
+
"locationTimeout": 30000,
|
|
18
|
+
"filter": {
|
|
19
|
+
"useKalman": false,
|
|
20
|
+
"trackingAccuracyThreshold": 100,
|
|
21
|
+
"maxImpliedSpeed": 80,
|
|
22
|
+
"odometerAccuracyThreshold": 20,
|
|
23
|
+
"allowIdenticalLocations": false,
|
|
24
|
+
"rejectMockLocations": false
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"activity": {
|
|
28
|
+
"disableMotionActivityUpdates": false,
|
|
29
|
+
"activityRecognitionInterval": 10000,
|
|
30
|
+
"minimumActivityRecognitionConfidence": 75,
|
|
31
|
+
"motionTriggerDelay": 0,
|
|
32
|
+
"disableStopDetection": false
|
|
33
|
+
},
|
|
34
|
+
"heartbeat": {
|
|
35
|
+
"enabled": true,
|
|
36
|
+
"minInterval": 180,
|
|
37
|
+
"maxInterval": 300,
|
|
38
|
+
"uploadKeepAwake": 10
|
|
39
|
+
},
|
|
40
|
+
"http": {
|
|
41
|
+
"url": null,
|
|
42
|
+
"method": "POST",
|
|
43
|
+
"headers": {},
|
|
44
|
+
"params": {},
|
|
45
|
+
"autoSync": true,
|
|
46
|
+
"autoSyncThreshold": 0,
|
|
47
|
+
"syncInterval": 0,
|
|
48
|
+
"batchSync": false,
|
|
49
|
+
"maxBatchSize": 100,
|
|
50
|
+
"disableAutoSyncOnCellular": false,
|
|
51
|
+
"rootProperty": "location",
|
|
52
|
+
"locationTemplate": null,
|
|
53
|
+
"geofenceTemplate": null,
|
|
54
|
+
"timeout": 60000,
|
|
55
|
+
"maxRetryAfter": 3600,
|
|
56
|
+
"authorization": null
|
|
57
|
+
},
|
|
58
|
+
"persistence": {
|
|
59
|
+
"maxDaysToPersist": 7,
|
|
60
|
+
"maxRecordsToPersist": -1,
|
|
61
|
+
"extras": {}
|
|
62
|
+
},
|
|
63
|
+
"app": {
|
|
64
|
+
"stopOnTerminate": true,
|
|
65
|
+
"startOnBoot": false
|
|
66
|
+
},
|
|
67
|
+
"notification": {
|
|
68
|
+
"title": null,
|
|
69
|
+
"largeIcon": null,
|
|
70
|
+
"color": null,
|
|
71
|
+
"text": "Location tracking is active",
|
|
72
|
+
"smallIcon": "drawable/lt_ic_notification",
|
|
73
|
+
"priority": "default",
|
|
74
|
+
"channelId": "location_tracking",
|
|
75
|
+
"channelName": "Location tracking",
|
|
76
|
+
"actions": [],
|
|
77
|
+
"resume": {
|
|
78
|
+
"title": null,
|
|
79
|
+
"enabled": false,
|
|
80
|
+
"text": "Location tracking is paused. Tap to resume.",
|
|
81
|
+
"channelName": "Paused location tracking",
|
|
82
|
+
"delay": 600
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"geofence": {
|
|
86
|
+
"initialTriggerEntry": true
|
|
87
|
+
},
|
|
88
|
+
"logger": {
|
|
89
|
+
"logLevel": "info",
|
|
90
|
+
"logMaxDays": 3
|
|
91
|
+
},
|
|
92
|
+
"backgroundPermissionRationale": {"title": null, "message": null, "positiveAction": null, "negativeAction": null},
|
|
93
|
+
"locationProvider": "auto"
|
|
94
|
+
}
|
|
95
|
+
"""#.utf8))
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreFoundation
|
|
3
|
+
|
|
4
|
+
struct ConfigSchema {
|
|
5
|
+
static let authorization: TrackingJSON = ["strategy": "JWT", "accessToken": NSNull(), "refreshToken": NSNull(), "refreshUrl": NSNull(), "refreshPayload": TrackingJSON(), "refreshHeaders": TrackingJSON(), "refreshPayloadEncoding": "json", "expires": -1]
|
|
6
|
+
static let maps = Set(["http.headers", "http.params", "persistence.extras", "http.authorization.refreshPayload", "http.authorization.refreshHeaders"])
|
|
7
|
+
static let enums = ["geolocation.desiredAccuracy": ["high", "balanced", "low", "passive"], "http.method": ["POST", "PUT", "PATCH"], "http.authorization.strategy": ["JWT"], "http.authorization.refreshPayloadEncoding": ["json", "form"], "notification.priority": ["min", "low", "default", "high", "max"], "logger.logLevel": ["off", "error", "warn", "info", "debug", "verbose"], "locationProvider": ["auto", "gms", "hms", "android"]]
|
|
8
|
+
static func parse(_ patch: TrackingJSON, base: TrackingJSON, defaults: TrackingJSON = ConfigDefaults.value, path: String = "", lenient: Bool = false, warnings: inout [String]) throws -> TrackingJSON {
|
|
9
|
+
var result = base
|
|
10
|
+
let schema = path == "http.authorization" ? authorization : defaults
|
|
11
|
+
for (key, raw) in patch {
|
|
12
|
+
let name = path.isEmpty ? key : path + "." + key
|
|
13
|
+
guard let fallback = schema[key] else { warnings.append("Ignoring unknown config key \(name)"); continue }
|
|
14
|
+
do {
|
|
15
|
+
if raw is NSNull { result[key] = fallback; continue }
|
|
16
|
+
if maps.contains(name) {
|
|
17
|
+
guard var map = raw as? TrackingJSON else { throw invalid(name) }
|
|
18
|
+
if name.hasSuffix("Headers") || name == "http.headers" {
|
|
19
|
+
var strings: TrackingJSON = [:]
|
|
20
|
+
for (field, value) in map where !(value is NSNull) {
|
|
21
|
+
if let text = value as? String { strings[field] = text }
|
|
22
|
+
else if value is NSNumber { strings[field] = String(decoding: try jsonData(value), as: UTF8.self) }
|
|
23
|
+
else { throw invalid(name) }
|
|
24
|
+
}
|
|
25
|
+
map = strings
|
|
26
|
+
}
|
|
27
|
+
if name.hasPrefix("http.authorization.") {
|
|
28
|
+
for (field, value) in map {
|
|
29
|
+
if let text = value as? String, let stored = base.object(key)[field] as? String, text == mask(stored) { map[field] = stored }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
result[key] = map
|
|
33
|
+
} else if name == "http.authorization" || fallback is TrackingJSON {
|
|
34
|
+
guard let object = raw as? TrackingJSON else { throw invalid(name) }
|
|
35
|
+
let nested = name == "http.authorization" ? authorization : fallback as? TrackingJSON ?? [:]
|
|
36
|
+
result[key] = try parse(object, base: base[key] as? TrackingJSON ?? nested, defaults: nested, path: name, lenient: lenient, warnings: &warnings)
|
|
37
|
+
} else if let allowed = enums[name] {
|
|
38
|
+
guard let text = raw as? String, let canonical = allowed.first(where: { $0.lowercased() == text.lowercased() }) else { throw invalid(name) }
|
|
39
|
+
result[key] = canonical
|
|
40
|
+
} else if name == "notification.actions" {
|
|
41
|
+
guard let actions = raw as? [TrackingJSON] else { throw invalid(name) }
|
|
42
|
+
result[key] = try actions.map { action -> TrackingJSON in
|
|
43
|
+
guard let id = action["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let label = action["label"] as? String else { throw invalid(name) }
|
|
44
|
+
return ["id": id, "label": label]
|
|
45
|
+
}
|
|
46
|
+
} else if let number = fallback as? NSNumber {
|
|
47
|
+
if CFGetTypeID(number) == CFBooleanGetTypeID() {
|
|
48
|
+
if let value = raw as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() { result[key] = value }
|
|
49
|
+
else if let text = raw as? String, ["true", "false"].contains(text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) { result[key] = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" }
|
|
50
|
+
else { throw invalid(name) }
|
|
51
|
+
} else {
|
|
52
|
+
let value: Double?
|
|
53
|
+
if let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { value = number.doubleValue }
|
|
54
|
+
else if let text = raw as? String { value = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
55
|
+
else { value = nil }
|
|
56
|
+
guard let value, value.isFinite, abs(value) < Double(Int64.max) / 2 else { throw invalid(name) }
|
|
57
|
+
let decimals = Set(["geolocation.distanceFilter", "geolocation.elasticityMultiplier", "geolocation.stationaryRadius", "geolocation.filter.trackingAccuracyThreshold", "geolocation.filter.maxImpliedSpeed", "geolocation.filter.odometerAccuracyThreshold"])
|
|
58
|
+
result[key] = decimals.contains(name) ? value : value.rounded(.towardZero)
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
guard let text = raw as? String else { throw invalid(name) }
|
|
62
|
+
if name == "http.authorization.accessToken" || name == "http.authorization.refreshToken", let stored = base[key] as? String, text == mask(stored) { result[key] = stored }
|
|
63
|
+
else { result[key] = text }
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
if !lenient { throw error }
|
|
67
|
+
warnings.append("Ignoring invalid stored config key \(name)")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
private static func invalid(_ path: String) -> TrackingError { TrackingError("INVALID_ARGUMENT", "Invalid config.\(path)") }
|
|
73
|
+
static func mask(_ text: String) -> String { text.count > 8 ? "***" + text.suffix(4) : "***" }
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct ConfigValidator {
|
|
4
|
+
static func clamp(_ candidate: TrackingJSON) -> (TrackingJSON, [String]) {
|
|
5
|
+
var result = candidate
|
|
6
|
+
var warnings: [String] = []
|
|
7
|
+
let minimums: [String: [String: Double]] = [
|
|
8
|
+
"geolocation": ["distanceFilter": 0, "locationUpdateInterval": 0, "fastestLocationUpdateInterval": 0, "elasticityMultiplier": 0, "stationaryRadius": 1, "stopTimeout": 0, "stopAfterElapsedMinutes": 0],
|
|
9
|
+
"activity": ["activityRecognitionInterval": 0, "minimumActivityRecognitionConfidence": 0, "motionTriggerDelay": 0],
|
|
10
|
+
"http": ["maxBatchSize": 1, "autoSyncThreshold": 0, "syncInterval": 0, "maxRetryAfter": 0],
|
|
11
|
+
"persistence": ["maxDaysToPersist": 1, "maxRecordsToPersist": -1], "logger": ["logMaxDays": 1]
|
|
12
|
+
]
|
|
13
|
+
for (section, fields) in minimums {
|
|
14
|
+
var values = result.object(section)
|
|
15
|
+
for (key, minimum) in fields {
|
|
16
|
+
let current = values.number(key, ConfigDefaults.value.object(section).number(key))
|
|
17
|
+
let maximum = section == "activity" && key == "minimumActivityRecognitionConfidence" ? 100.0 : Double(Int32.max)
|
|
18
|
+
let clamped = min(maximum, max(minimum, current))
|
|
19
|
+
if clamped != current { warnings.append("\(section).\(key) clamped to \(clamped)") }
|
|
20
|
+
values[key] = clamped
|
|
21
|
+
}
|
|
22
|
+
result[section] = values
|
|
23
|
+
}
|
|
24
|
+
for section in ["http", "geolocation"] {
|
|
25
|
+
let key = section == "http" ? "timeout" : "locationTimeout"
|
|
26
|
+
var values = result.object(section)
|
|
27
|
+
if values.number(key) <= 0 {
|
|
28
|
+
values[key] = ConfigDefaults.value.object(section)[key]
|
|
29
|
+
warnings.append("\(section).\(key) raised to its default")
|
|
30
|
+
}
|
|
31
|
+
result[section] = values
|
|
32
|
+
}
|
|
33
|
+
var geo = result.object("geolocation")
|
|
34
|
+
var filter = geo.object("filter")
|
|
35
|
+
for key in ["trackingAccuracyThreshold", "maxImpliedSpeed", "odometerAccuracyThreshold"] { filter[key] = max(0, filter.number(key, ConfigDefaults.value.object("geolocation").object("filter").number(key))) }
|
|
36
|
+
geo["filter"] = filter
|
|
37
|
+
result["geolocation"] = geo
|
|
38
|
+
return (result, warnings)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
final class SecretStore {
|
|
5
|
+
private let service: String
|
|
6
|
+
init(service: String = "com.brickssoft.locationtracking.authorization") { self.service = service }
|
|
7
|
+
private var query: TrackingJSON {
|
|
8
|
+
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "http"]
|
|
9
|
+
}
|
|
10
|
+
func load() throws -> TrackingJSON? {
|
|
11
|
+
var request = query
|
|
12
|
+
request[kSecReturnData as String] = true
|
|
13
|
+
request[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
14
|
+
var result: CFTypeRef?
|
|
15
|
+
let status = SecItemCopyMatching(request as CFDictionary, &result)
|
|
16
|
+
if status == errSecItemNotFound { return nil }
|
|
17
|
+
guard status == errSecSuccess, let data = result as? Data else { throw TrackingError("IO_ERROR", "Keychain authorization is unavailable (status \(status))") }
|
|
18
|
+
return try jsonObject(data)
|
|
19
|
+
}
|
|
20
|
+
func save(_ value: TrackingJSON?) throws {
|
|
21
|
+
if let value {
|
|
22
|
+
let data = try jsonData(value)
|
|
23
|
+
let attributes: TrackingJSON = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
|
24
|
+
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
25
|
+
if status == errSecItemNotFound {
|
|
26
|
+
var request = query
|
|
27
|
+
attributes.forEach { request[$0.key] = $0.value }
|
|
28
|
+
guard SecItemAdd(request as CFDictionary, nil) == errSecSuccess else { throw TrackingError("IO_ERROR", "Cannot save authorization in Keychain") }
|
|
29
|
+
} else if status != errSecSuccess { throw TrackingError("IO_ERROR", "Cannot update authorization in Keychain") }
|
|
30
|
+
} else {
|
|
31
|
+
let status = SecItemDelete(query as CFDictionary)
|
|
32
|
+
guard status == errSecSuccess || status == errSecItemNotFound else { throw TrackingError("IO_ERROR", "Cannot clear authorization in Keychain") }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class TrackingConfig {
|
|
4
|
+
private(set) var value: TrackingJSON
|
|
5
|
+
private(set) var secretsUnavailable = false
|
|
6
|
+
private let store: TrackingStore
|
|
7
|
+
private let secrets: SecretStore
|
|
8
|
+
init(store: TrackingStore, secrets: SecretStore) throws {
|
|
9
|
+
self.store = store
|
|
10
|
+
self.secrets = secrets
|
|
11
|
+
var warnings: [String] = []
|
|
12
|
+
value = try ConfigSchema.parse(try store.load("config") ?? [:], base: ConfigDefaults.value, lenient: true, warnings: &warnings)
|
|
13
|
+
if (try store.load("authorization"))?.flag("configured") == true {
|
|
14
|
+
var http = value.object("http")
|
|
15
|
+
do {
|
|
16
|
+
guard let auth = try secrets.load() else { throw TrackingError("IO_ERROR", "Stored authorization is missing from Keychain") }
|
|
17
|
+
http["authorization"] = auth
|
|
18
|
+
} catch {
|
|
19
|
+
secretsUnavailable = true
|
|
20
|
+
http["authorization"] = TrackingJSON()
|
|
21
|
+
}
|
|
22
|
+
value["http"] = http
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
@discardableResult func apply(_ patch: TrackingJSON, reset: Bool) throws -> [String] {
|
|
26
|
+
var warnings: [String] = []
|
|
27
|
+
var candidate = try ConfigSchema.parse(patch, base: reset ? ConfigDefaults.value : value, warnings: &warnings)
|
|
28
|
+
let clamped = ConfigValidator.clamp(candidate)
|
|
29
|
+
candidate = clamped.0
|
|
30
|
+
warnings += clamped.1
|
|
31
|
+
var heartbeat = candidate.object("heartbeat")
|
|
32
|
+
heartbeat["minInterval"] = max(60, heartbeat.number("minInterval", 180))
|
|
33
|
+
heartbeat["maxInterval"] = max(heartbeat.number("minInterval"), heartbeat.number("maxInterval", 300))
|
|
34
|
+
heartbeat["uploadKeepAwake"] = min(60, max(0, heartbeat.number("uploadKeepAwake", 10)))
|
|
35
|
+
candidate["heartbeat"] = heartbeat
|
|
36
|
+
var notification = candidate.object("notification")
|
|
37
|
+
var resume = notification.object("resume")
|
|
38
|
+
if resume.number("delay", 600) < heartbeat.number("maxInterval") + 60 {
|
|
39
|
+
resume["delay"] = heartbeat.number("maxInterval") + 60
|
|
40
|
+
warnings.append("notification.resume.delay raised to heartbeat.maxInterval + 60")
|
|
41
|
+
}
|
|
42
|
+
notification["resume"] = resume
|
|
43
|
+
candidate["notification"] = notification
|
|
44
|
+
let http = candidate.object("http")
|
|
45
|
+
if let url = http["url"] as? String, !url.isEmpty {
|
|
46
|
+
guard let parsed = URL(string: url), ["https", "http"].contains(parsed.scheme?.lowercased() ?? ""), parsed.host != nil else { throw TrackingError("INVALID_ARGUMENT", "http.url must be an HTTP URL") }
|
|
47
|
+
if parsed.scheme == "http" { warnings.append("http.url uses unencrypted HTTP; iOS App Transport Security may refuse it") }
|
|
48
|
+
}
|
|
49
|
+
for section in ["geolocation", "activity", "heartbeat", "http", "persistence", "logger"] {
|
|
50
|
+
try Self.validateNumbers(candidate.object(section))
|
|
51
|
+
}
|
|
52
|
+
guard ["POST", "PUT", "PATCH"].contains(http.text("method", "POST")), http.number("timeout", 60000) > 0, http.number("maxBatchSize", 100) >= 1 else { throw TrackingError("INVALID_ARGUMENT", "Invalid HTTP method, timeout or batch size") }
|
|
53
|
+
_ = try jsonData(candidate)
|
|
54
|
+
var persisted = candidate
|
|
55
|
+
var persistedHttp = http
|
|
56
|
+
persistedHttp["authorization"] = NSNull()
|
|
57
|
+
persisted["http"] = persistedHttp
|
|
58
|
+
let previousSecrets = value.object("http")["authorization"] as? TrackingJSON
|
|
59
|
+
let nextSecrets = http["authorization"] as? TrackingJSON
|
|
60
|
+
let differentSecrets = try jsonData(previousSecrets ?? [:]) != jsonData(nextSecrets ?? [:])
|
|
61
|
+
let explicitAuthorization = patch.object("http").keys.contains("authorization") || reset
|
|
62
|
+
let changedSecrets = (previousSecrets == nil) != (nextSecrets == nil) || differentSecrets || (secretsUnavailable && explicitAuthorization)
|
|
63
|
+
if changedSecrets { try secrets.save(nextSecrets) }
|
|
64
|
+
do {
|
|
65
|
+
try store.transaction {
|
|
66
|
+
try store.save("config", persisted)
|
|
67
|
+
try store.save("authorization", ["configured": nextSecrets != nil])
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
if changedSecrets {
|
|
71
|
+
do { try secrets.save(previousSecrets) }
|
|
72
|
+
catch { throw TrackingError("IO_ERROR", "Configuration write and authorization rollback failed") }
|
|
73
|
+
}
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
value = candidate
|
|
77
|
+
if explicitAuthorization { secretsUnavailable = false }
|
|
78
|
+
return warnings
|
|
79
|
+
}
|
|
80
|
+
var masked: TrackingJSON {
|
|
81
|
+
var result = value
|
|
82
|
+
var http = result.object("http")
|
|
83
|
+
if var auth = http["authorization"] as? TrackingJSON {
|
|
84
|
+
for key in ["accessToken", "refreshToken"] {
|
|
85
|
+
if let text = auth[key] as? String { auth[key] = ConfigSchema.mask(text) }
|
|
86
|
+
}
|
|
87
|
+
for key in ["refreshPayload", "refreshHeaders"] {
|
|
88
|
+
var map = auth.object(key)
|
|
89
|
+
for (name, value) in map {
|
|
90
|
+
if let text = value as? String, !text.contains("{refreshToken}") { map[name] = ConfigSchema.mask(text) }
|
|
91
|
+
}
|
|
92
|
+
auth[key] = map
|
|
93
|
+
}
|
|
94
|
+
http["authorization"] = auth
|
|
95
|
+
}
|
|
96
|
+
result["http"] = http
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
private static func validateNumbers(_ object: TrackingJSON) throws {
|
|
100
|
+
for (_, value) in object {
|
|
101
|
+
if let number = value as? NSNumber, !number.doubleValue.isFinite || abs(number.doubleValue) >= Double(Int64.max) / 2 { throw TrackingError("INVALID_ARGUMENT", "Configuration number is out of range") }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public typealias TrackingJSON = [String: Any]
|
|
4
|
+
|
|
5
|
+
public struct TrackingError: Error, LocalizedError {
|
|
6
|
+
public let code: String
|
|
7
|
+
public let message: String
|
|
8
|
+
var storageFull = false
|
|
9
|
+
public var errorDescription: String? { message }
|
|
10
|
+
public init(_ code: String, _ message: String) { self.code = code; self.message = message }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func jsonData(_ value: Any) throws -> Data {
|
|
14
|
+
guard JSONSerialization.isValidJSONObject(value) || value is NSNull || value is String || value is NSNumber else {
|
|
15
|
+
throw TrackingError("INVALID_ARGUMENT", "Value must be JSON serializable")
|
|
16
|
+
}
|
|
17
|
+
return try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed, .sortedKeys])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func jsonObject(_ data: Data) throws -> TrackingJSON {
|
|
21
|
+
guard let result = try JSONSerialization.jsonObject(with: data) as? TrackingJSON else {
|
|
22
|
+
throw TrackingError("IO_ERROR", "Expected a JSON object")
|
|
23
|
+
}
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private let isoFormatter: ISO8601DateFormatter = {
|
|
28
|
+
let formatter = ISO8601DateFormatter()
|
|
29
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
30
|
+
return formatter
|
|
31
|
+
}()
|
|
32
|
+
|
|
33
|
+
func iso(_ date: Date = Date()) -> String { isoFormatter.string(from: date) }
|
|
34
|
+
|
|
35
|
+
func isoDate(_ text: String) -> Date? { isoFormatter.date(from: text) }
|
|
36
|
+
|
|
37
|
+
extension Dictionary where Key == String, Value == Any {
|
|
38
|
+
func object(_ key: String) -> TrackingJSON { self[key] as? TrackingJSON ?? [:] }
|
|
39
|
+
func number(_ key: String, _ fallback: Double = 0) -> Double { (self[key] as? NSNumber)?.doubleValue ?? fallback }
|
|
40
|
+
func flag(_ key: String, _ fallback: Bool = false) -> Bool { self[key] as? Bool ?? fallback }
|
|
41
|
+
func text(_ key: String, _ fallback: String = "") -> String { self[key] as? String ?? fallback }
|
|
42
|
+
func requiringObject(_ key: String) throws -> TrackingJSON {
|
|
43
|
+
guard let value = self[key] as? TrackingJSON else { throw TrackingError("INVALID_ARGUMENT", "\(key) must be an object") }
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
func requiringString(_ key: String) throws -> String {
|
|
47
|
+
guard let value = self[key] as? String, !value.isEmpty else { throw TrackingError("INVALID_ARGUMENT", "\(key) must be a nonempty string") }
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreFoundation
|
|
3
|
+
|
|
4
|
+
struct Options {
|
|
5
|
+
static func validate(_ method: String, _ options: TrackingJSON) throws {
|
|
6
|
+
let numbers: [String: [String]] = ["getLocations": ["limit"], "setOdometer": ["odometer"], "getCurrentPosition": ["samples", "timeout", "maximumAge"], "watchPosition": ["interval"], "getLog": ["start", "end", "limit"]]
|
|
7
|
+
for key in numbers[method] ?? [] {
|
|
8
|
+
if let value = options[key], !(value is NSNull) {
|
|
9
|
+
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite, abs(number.doubleValue) < Double(Int64.max) / 2 else { throw TrackingError("INVALID_ARGUMENT", "\(key) must be a finite number in range") }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
for key in ["persist", "reset", "isMoving"] {
|
|
13
|
+
if let value = options[key], !(value is NSNull), !(value is NSNumber && CFGetTypeID(value as! NSNumber) == CFBooleanGetTypeID()) { throw TrackingError("INVALID_ARGUMENT", "\(key) must be a boolean") }
|
|
14
|
+
}
|
|
15
|
+
for key in ["config", "extras", "headers", "params", "location", "geofence"] {
|
|
16
|
+
if let value = options[key], !(value is NSNull), !(value is TrackingJSON) { throw TrackingError("INVALID_ARGUMENT", "\(key) must be an object") }
|
|
17
|
+
}
|
|
18
|
+
if let desired = options["desiredAccuracy"], !(desired is NSNull) {
|
|
19
|
+
guard let text = desired as? String, ["high", "balanced", "low", "passive"].contains(text) else { throw TrackingError("INVALID_ARGUMENT", "Unknown desiredAccuracy") }
|
|
20
|
+
}
|
|
21
|
+
if method == "requestPermissions", let value = options["permissions"], !(value is [String]) { throw TrackingError("INVALID_ARGUMENT", "permissions must be a string array") }
|
|
22
|
+
if method == "log" {
|
|
23
|
+
guard ["error", "warn", "info", "debug", "verbose"].contains(options.text("level")) else { throw TrackingError("INVALID_ARGUMENT", "Unknown log level") }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Darwin
|
|
3
|
+
|
|
4
|
+
enum TrackingClock {
|
|
5
|
+
static func now() -> TrackingInstant { TrackingInstant(wall: Date().timeIntervalSince1970 * 1000, elapsed: elapsedMilliseconds()) }
|
|
6
|
+
static func elapsedMilliseconds() -> Double {
|
|
7
|
+
var info = mach_timebase_info_data_t()
|
|
8
|
+
mach_timebase_info(&info)
|
|
9
|
+
return Double(mach_continuous_time()) * Double(info.numer) / Double(info.denom) / 1e6
|
|
10
|
+
}
|
|
11
|
+
}
|