@bricks-soft/capacitor-location-tracking 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +107 -29
  4. package/android/build.gradle +3 -1
  5. package/dist/esm/definitions.d.ts +10 -4
  6. package/dist/esm/definitions.js.map +1 -1
  7. package/dist/esm/web/device.d.ts +1 -1
  8. package/dist/esm/web/device.js +1 -1
  9. package/dist/esm/web/device.js.map +1 -1
  10. package/dist/plugin.cjs.js +1 -1
  11. package/dist/plugin.cjs.js.map +1 -1
  12. package/dist/plugin.js +1 -1
  13. package/dist/plugin.js.map +1 -1
  14. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  15. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  16. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  17. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  18. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  19. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  20. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  21. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  22. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  23. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  24. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  25. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  26. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  27. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  28. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  29. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  30. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  31. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  32. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  33. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  34. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  35. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  36. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  37. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  38. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  39. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  40. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  41. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  42. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  43. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  44. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  45. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  46. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  47. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  48. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  49. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  50. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  51. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  52. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  53. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  54. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  55. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  56. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  57. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  58. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  59. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  60. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  61. package/package.json +9 -3
@@ -0,0 +1,298 @@
1
+ import Foundation
2
+ import CoreLocation
3
+ import CoreMotion
4
+ import UIKit
5
+ import Network
6
+
7
+ final class TrackingCore {
8
+ let queue: DispatchQueue
9
+ let store: TrackingStore
10
+ let config: TrackingConfig
11
+ let log: TrackingLog
12
+ let logExport = LogExport()
13
+ let permissions: Permissions
14
+ let positions: Positions
15
+ let emit: (String, TrackingJSON) -> Void
16
+ let record: (TrackingJSON) -> Void
17
+ var ready = false
18
+ var odometer: Double { runtime.number("odometer") }
19
+ var runtime: TrackingJSON
20
+ let clock: () -> TrackingInstant
21
+ let automaticTimers: Bool
22
+ var routeSource: RouteSource?
23
+ lazy var engine = TrackingEngine(core: self)
24
+ private var network = "other"
25
+ private var watchIDs = Set<String>()
26
+ private let monitor = NWPathMonitor()
27
+ lazy var syncer = Syncer(queue: queue, store: store, config: config, emit: emit)
28
+
29
+ init(queue: DispatchQueue, permissions: Permissions, positions: Positions, emit: @escaping (String, TrackingJSON) -> Void, record: @escaping (TrackingJSON) -> Void, url: URL? = nil, clock: @escaping () -> TrackingInstant = TrackingClock.now, automaticTimers: Bool = true, geofenceBackend: GeofenceBackend? = nil) throws {
30
+ self.clock = clock; self.automaticTimers = automaticTimers
31
+ self.queue = queue; self.permissions = permissions; self.positions = positions; self.emit = emit; self.record = record
32
+ let path = try url ?? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("LocationTracking/tracking.sqlite")
33
+ store = try TrackingStore(url: path)
34
+ log = TrackingLog(store: store)
35
+ config = try TrackingConfig(store: store, secrets: SecretStore())
36
+ log.configure(config.value.object("logger"))
37
+ let persistence = config.value.object("persistence")
38
+ try store.prune(days: Int(persistence.number("maxDaysToPersist", 7)), maximum: Int(persistence.number("maxRecordsToPersist", -1)), now: Date(timeIntervalSince1970: clock().wall / 1000))
39
+ runtime = try store.load("runtime") ?? [:]
40
+ if !(TrackingInstant.from(runtime.object("clockCheckpoint")).map { clock().continues($0) } ?? false), var stored = TrackedLocation.from(runtime.object("lastLocation")), stored.elapsed != nil {
41
+ stored.elapsed = nil; runtime["lastLocation"] = stored.json
42
+ }
43
+ monitor.pathUpdateHandler = { [weak self] path in
44
+ guard let self else { return }
45
+ self.network = path.status != .satisfied ? "none" : path.usesInterfaceType(.cellular) ? "cellular" : path.usesInterfaceType(.wifi) ? "wifi" : path.usesInterfaceType(.wiredEthernet) ? "ethernet" : "other"
46
+ self.syncer.connectivity(connected: path.status == .satisfied, cellular: path.usesInterfaceType(.cellular))
47
+ self.emit("connectivitychange", ["connected": self.syncer.connected, "type": self.network])
48
+ }
49
+ monitor.start(queue: queue)
50
+ if let geofenceBackend { attachGeofences(geofenceBackend) }
51
+ else if #available(iOS 17.0, *) {
52
+ let manager = permissions.manager
53
+ attachGeofences(GeofenceMonitor.shared(queue: queue) { manager.authorizationStatus })
54
+ }
55
+ }
56
+ var geofences: GeofenceManager? { engine.geofences }
57
+ private func attachGeofences(_ backend: GeofenceBackend) {
58
+ let manager = GeofenceManager(store: SQLiteGeofenceStore(store: store), backend: backend, queue: queue, now: { [unowned self] in self.clock().wall }, automaticTimers: automaticTimers, initialTriggerEntry: { [unowned self] in self.config.value.object("geofence").flag("initialTriggerEntry", true) }, enabled: { [unowned self] in self.runtime.flag("enabled") }, storedLocation: { [unowned self] in TrackedLocation.from(self.runtime.object("lastLocation")) }, notify: { [unowned self] in self.engine.recordGeofence($0, $1, $2) }, changed: { [unowned self] on, off in self.emit("geofenceschange", ["on": on.map(\.json), "off": off]) }, log: { [unowned self] level, message in self.log.write(level, "Geofences: " + message) })
59
+ manager.demandChanged = { [unowned self] in self.engine.geofenceDemandChanged($0) }
60
+ manager.blocked = { [unowned self] in self.engine.geofencesBlocked() }
61
+ if let monitor = backend as? GeofenceMonitor {
62
+ monitor.transitions = { [weak manager] in manager?.onTransitions($0) }
63
+ monitor.failures = { [weak self] in self?.engine.geofenceFailures($0) }
64
+ }
65
+ engine.geofences = manager
66
+ }
67
+ private func geofenceManager() throws -> GeofenceManager {
68
+ guard let geofences else { throw TrackingError("UNAVAILABLE", "Geofencing requires iOS 17") }
69
+ return geofences
70
+ }
71
+ deinit { monitor.cancel() }
72
+ static let allowed = Set(["ready", "getState", "checkPermissions", "requestPermissions", "getDeviceInfo", "getSensors", "log", "getLog", "getProviderState", "openBatteryOptimizationSettings", "openPowerManagerSettings", "openLocationSettings", "openAppSettings"])
73
+ func state() -> TrackingJSON {
74
+ ["enabled": runtime.flag("enabled"), "trackingMode": runtime.text("trackingMode", "location"), "isMoving": runtime.flag("isMoving"), "odometer": odometer, "backend": PluginInfo.backend, "lastRecordAt": runtime["lastRecordAt"] ?? NSNull(), "lastStopReason": runtime["lastStopReason"] ?? NSNull(), "lastStopAt": runtime["lastStopAt"] ?? NSNull(), "config": config.masked]
75
+ }
76
+ func call(_ method: String, _ options: TrackingJSON, native: Bool = false, callback: @escaping (Result<TrackingJSON, Error>) -> Void) {
77
+ do {
78
+ guard native || ready || Self.allowed.contains(method) else { throw TrackingError("NOT_READY", "Call ready() before using this method") }
79
+ try Options.validate(method, options)
80
+ switch method {
81
+ case "ready", "reset", "setConfig":
82
+ let patch = method == "setConfig" ? try options.requiringObject("config") : options.object("config")
83
+ let resetting = method == "reset" || (method == "ready" && options.flag("reset", true))
84
+ let existing = try store.load("config")
85
+ let shouldApply = method != "ready" || resetting || existing == nil
86
+ let warnings = shouldApply ? try config.apply(patch, reset: resetting) : []
87
+ log.configure(config.value.object("logger"))
88
+ if shouldApply { engine.configChanged() }
89
+ if method == "ready" && !native { ready = true }
90
+ var result = state(); result["warnings"] = warnings
91
+ callback(.success(result)); syncer.automatic()
92
+ case "getState": callback(.success(state()))
93
+ case "start", "startGeofences":
94
+ let mode = method == "start" ? "location" : "geofences"
95
+ if mode == "geofences" { _ = try geofenceManager() }
96
+ DispatchQueue.main.async {
97
+ let provider = self.permissions.provider
98
+ let foreground = UIApplication.shared.applicationState != .background
99
+ let lastLocation = self.routeSource?.lastLocation
100
+ self.queue.async {
101
+ do { self.engine.context(provider: provider, foreground: foreground); self.engine.offerLastLocation(lastLocation); try self.engine.start(mode: mode); callback(.success(self.state())) }
102
+ catch { callback(.failure(error)) }
103
+ }
104
+ }
105
+ case "stop": try engine.stop(); callback(.success(state()))
106
+ case "changePace":
107
+ guard let moving = options["isMoving"] as? Bool else { throw TrackingError("INVALID_ARGUMENT", "isMoving is required") }
108
+ engine.changePace(moving); callback(.success([:]))
109
+ case "getHeartbeatStatus": callback(.success(engine.heartbeatStatus()))
110
+ case "getOdometer": callback(.success(["odometer": odometer]))
111
+ case "setOdometer", "resetOdometer":
112
+ let value = method == "resetOdometer" ? 0 : options.number("odometer", -1)
113
+ guard value.isFinite && value >= 0 else { throw TrackingError("INVALID_ARGUMENT", "odometer must be a finite number >= 0") }
114
+ try updateRuntime(["odometer": value])
115
+ if method == "resetOdometer" { engine.odometer.reset(session: runtime["trackingStartedAt"] as? Double) }
116
+ callback(.success(["odometer": value]))
117
+ case "getLocations": callback(.success(["locations": try store.locations(limit: max(0, Int(options.number("limit"))))]))
118
+ case "getCount": callback(.success(["count": try store.count]))
119
+ case "destroyLocations": callback(.success(["count": try store.clear()]))
120
+ case "destroyLocation": callback(.success(["deleted": try store.delete(options.requiringString("uuid"))]))
121
+ case "insertLocation": insert(try options.requiringObject("location"), callback: callback)
122
+ case "sync": syncer.run { callback($0.map { ["locations": $0] }) }
123
+ case "getCurrentPosition", "watchPosition":
124
+ let watch = method == "watchPosition"
125
+ let id = options.text("_watchId", UUID().uuidString)
126
+ if watch { watchIDs.insert(id) }
127
+ var request = options
128
+ request["_rejectMock"] = config.value.object("geolocation").object("filter").flag("rejectMockLocations")
129
+ if !watch && request["timeout"] == nil { request["timeout"] = config.value.object("geolocation").number("locationTimeout", 30000) }
130
+ guard request.number("timeout", 30000) > 0, request.number("samples", 3) >= 1, request.number("maximumAge") >= 0, request.number("interval", 1000) >= 0 else { throw TrackingError("INVALID_ARGUMENT", "Invalid position options") }
131
+ DispatchQueue.main.async {
132
+ self.positions.request(id: id, options: request, watch: watch) { result in
133
+ let provider = self.permissions.provider
134
+ self.queue.async {
135
+ if watch && !self.watchIDs.contains(id) { return }
136
+ do {
137
+ let fix = try result.get()
138
+ if watch && self.config.value.object("geolocation").object("filter").flag("rejectMockLocations") && fix.sourceInformation?.isSimulatedBySoftware == true { return }
139
+ let extras = self.config.value.object("persistence").object("extras").merging(options.object("extras")) { _, new in new }
140
+ var location = TrackedLocation(fix)
141
+ if self.runtime.flag("enabled") { location.session = self.runtime["trackingStartedAt"] as? Double }
142
+ let wire = self.makeRecord(event: watch ? "watch_position" : "current_position", fix: location, extras: extras, provider: provider)
143
+ if options.flag("persist", !watch) { self.deliver(wire, location: location) }
144
+ callback(.success(wire))
145
+ } catch { callback(.failure(error)) }
146
+ }
147
+ }
148
+ }
149
+ case "clearWatch":
150
+ let id = try options.requiringString("id")
151
+ watchIDs.remove(id)
152
+ DispatchQueue.main.async { self.positions.clear(id) }
153
+ callback(.success([:]))
154
+ case "getProviderState": DispatchQueue.main.async { LocationServices.shared.whenReady { callback(.success(self.permissions.provider)) } }
155
+ case "checkPermissions": DispatchQueue.main.async { self.permissions.status { callback(.success($0)) } }
156
+ case "requestPermissions":
157
+ let types = options["permissions"] as? [String] ?? Permissions.order
158
+ guard types.allSatisfy({ Permissions.order.contains($0) }) else { throw TrackingError("INVALID_ARGUMENT", "Unknown permission") }
159
+ let ordered = Permissions.order.filter { types.contains($0) }
160
+ DispatchQueue.main.async { self.permissions.request(ordered) { callback(.success($0)) } }
161
+ case "isPowerSaveMode": callback(.success(["isPowerSaveMode": ProcessInfo.processInfo.isLowPowerModeEnabled]))
162
+ case "getBatteryOptimizationStatus": callback(.success(["isIgnoringBatteryOptimizations": false, "canScheduleExactAlarms": false, "isDeviceIdleMode": false]))
163
+ case "getPowerManagerInfo": callback(.success(["manufacturer": "Apple", "available": false]))
164
+ case "openBatteryOptimizationSettings", "openPowerManagerSettings": callback(.success(["opened": false]))
165
+ case "openAppSettings", "openLocationSettings":
166
+ DispatchQueue.main.async { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) { callback(.success(["opened": $0])) } }
167
+ case "getDeviceInfo":
168
+ DispatchQueue.main.async {
169
+ callback(.success(["platform": "ios", "manufacturer": "Apple", "model": UIDevice.current.model, "brand": "Apple", "osVersion": UIDevice.current.systemVersion, "sdkInt": 0, "pluginVersion": PluginInfo.version, "gmsAvailable": false, "hmsAvailable": false, "backend": PluginInfo.backend, "packagedProviders": [PluginInfo.backend], "backendFallbackReason": NSNull(), "gmsStatus": NSNull(), "hmsStatus": NSNull(), "hmsCoreOutdated": NSNull(), "locationAvailableHint": NSNull(), "cpuCores": ProcessInfo.processInfo.processorCount, "supportedAbis": []]))
170
+ }
171
+ case "getSensors":
172
+ let motion = CMMotionManager()
173
+ callback(.success(["accelerometer": motion.isAccelerometerAvailable, "gyroscope": motion.isGyroAvailable, "magnetometer": motion.isMagnetometerAvailable, "significantMotion": CMMotionActivityManager.isActivityAvailable(), "stepCounter": CMPedometer.isStepCountingAvailable(), "stepDetector": CMPedometer.isStepCountingAvailable(), "barometer": CMAltimeter.isRelativeAltitudeAvailable()]))
174
+ case "log":
175
+ guard let message = options["message"] as? String else { throw TrackingError("INVALID_ARGUMENT", "message must be a string") }
176
+ log.write(try options.requiringString("level"), message); callback(.success([:]))
177
+ case "getLog": callback(.success(["log": log.read(options)]))
178
+ case "destroyLog": try log.clear(); callback(.success([:]))
179
+ case "addGeofence": try geofenceManager().add([GeofenceSpec.parse(options.requiringObject("geofence"))]); callback(.success([:]))
180
+ case "addGeofences":
181
+ let manager = try geofenceManager()
182
+ guard let array = options["geofences"] as? [Any] else { throw TrackingError("INVALID_ARGUMENT", "geofences must be an array") }
183
+ let specs = try array.enumerated().map { index, item -> GeofenceSpec in
184
+ guard let object = item as? TrackingJSON else { throw TrackingError("INVALID_ARGUMENT", "geofences[\(index)] must be an object") }
185
+ return try GeofenceSpec.parse(object)
186
+ }
187
+ if !specs.isEmpty { try manager.add(specs) }
188
+ callback(.success([:]))
189
+ case "removeGeofence": try geofenceManager().remove([options.requiringString("identifier")]); callback(.success([:]))
190
+ case "removeGeofences":
191
+ let manager = try geofenceManager()
192
+ if options["identifiers"] == nil || options["identifiers"] is NSNull { try manager.removeAll() }
193
+ else {
194
+ guard let array = options["identifiers"] as? [Any] else { throw TrackingError("INVALID_ARGUMENT", "identifiers must be an array") }
195
+ let ids = try array.enumerated().map { index, item -> String in
196
+ guard let id = item as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw TrackingError("INVALID_ARGUMENT", "identifiers[\(index)] must be a non-empty string") }
197
+ return id
198
+ }
199
+ if !ids.isEmpty { try manager.remove(ids) }
200
+ }
201
+ callback(.success([:]))
202
+ case "getGeofences": callback(.success(["geofences": try geofenceManager().list().map(\.json)]))
203
+ case "getGeofence": callback(.success(["geofence": try geofenceManager().get(options.requiringString("identifier"))?.json ?? NSNull()]))
204
+ case "geofenceExists": callback(.success(["exists": try geofenceManager().get(options.requiringString("identifier")) != nil]))
205
+ case "uploadLog": try logExport.upload(log.read([:]), options: options, completion: callback)
206
+ case "emailLog":
207
+ let text = log.read([:])
208
+ DispatchQueue.main.async { self.logExport.email(text, options: options, completion: callback) }
209
+ default: throw TrackingError("UNIMPLEMENTED", "Unknown method: \(method)")
210
+ }
211
+ } catch { callback(.failure(error)) }
212
+ }
213
+ private func insert(_ input: TrackingJSON, callback: @escaping (Result<TrackingJSON, Error>) -> Void) {
214
+ do {
215
+ let now = clock()
216
+ let parsed = try ExternalRecord(input, now: now.wall)
217
+ let extras = config.value.object("persistence").object("extras").merging(parsed.extras) { _, new in new }
218
+ let capturedOdometer = odometer
219
+ let capturedNetwork = network
220
+ let capturedMoving = runtime.flag("isMoving")
221
+ let capturedActivity = runtime["activity"] ?? ["type": "unknown", "confidence": 0]
222
+ DispatchQueue.main.async {
223
+ let provider = self.permissions.provider
224
+ var wire = RecordFactory.make(event: parsed.event, fix: parsed.location.location, extras: extras, odometer: capturedOdometer, provider: provider, network: capturedNetwork)
225
+ wire["is_moving"] = parsed.moving ?? capturedMoving
226
+ wire["activity"] = capturedActivity
227
+ wire["coords"] = parsed.coordinates
228
+ wire["recorded_at"] = iso(Date(timeIntervalSince1970: now.wall / 1000))
229
+ wire["elapsed_realtime_ms"] = now.elapsed
230
+ self.queue.async {
231
+ do { try self.persist(wire, updateRuntime: false); self.record(wire); self.syncer.automatic(); callback(.success(["uuid": wire.text("uuid")])) }
232
+ catch { callback(.failure(error)) }
233
+ }
234
+ }
235
+ } catch { callback(.failure(error)) }
236
+ }
237
+ func updateRuntime(_ changes: TrackingJSON) throws {
238
+ var next = runtime.merging(changes) { _, new in new }
239
+ next["clockCheckpoint"] = clock().json
240
+ try store.save("runtime", next)
241
+ runtime = next
242
+ }
243
+ func makeRecord(event: String, fix: TrackedLocation?, extras: TrackingJSON? = nil, provider: TrackingJSON? = nil) -> TrackingJSON {
244
+ var wire = RecordFactory.make(event: event, fix: fix?.location, extras: extras ?? config.value.object("persistence").object("extras"), odometer: odometer, provider: provider ?? engine.provider, network: network)
245
+ wire["is_moving"] = runtime.flag("isMoving")
246
+ wire["activity"] = runtime["activity"] ?? ["type": "unknown", "confidence": 0]
247
+ wire["mock"] = fix?.isMock ?? false
248
+ let now = clock()
249
+ wire["recorded_at"] = iso(Date(timeIntervalSince1970: now.wall / 1000))
250
+ wire["elapsed_realtime_ms"] = now.elapsed
251
+ return wire
252
+ }
253
+ private func recordedRuntime(_ wire: TrackingJSON, location: TrackedLocation?) -> TrackingJSON {
254
+ var next = runtime
255
+ next["lastRecordAt"] = wire["recorded_at"]
256
+ next["lastRecordElapsed"] = wire["elapsed_realtime_ms"] ?? clock().elapsed
257
+ next["clockCheckpoint"] = clock().json
258
+ if let location { next["lastLocation"] = location.json }
259
+ if wire.text("event") == "heartbeat" { next["lastHeartbeatAt"] = wire["recorded_at"] }
260
+ return next
261
+ }
262
+ func deliver(_ wire: TrackingJSON, location: TrackedLocation? = nil, onUpload: (() -> Void)? = nil) {
263
+ var stored = false
264
+ do { try persist(wire, updateRuntime: true, location: location); stored = true }
265
+ catch {
266
+ log.write("error", "Failed to persist position")
267
+ if (error as? TrackingError)?.storageFull == true { emit("storagefull", ["uuid": wire.text("uuid"), "event": wire.text("event"), "diskFreeBytes": NSNull()]) }
268
+ runtime = recordedRuntime(wire, location: location)
269
+ }
270
+ record(wire)
271
+ switch wire.text("event") {
272
+ case "location", "current_position", "watch_position": emit("location", wire)
273
+ case "motionchange": emit("location", wire); emit("motionchange", ["isMoving": wire.flag("is_moving"), "location": wire])
274
+ case "heartbeat": emit("heartbeat", ["location": wire])
275
+ case "geofence":
276
+ let hit = wire.object("geofence")
277
+ var payload: TrackingJSON = ["identifier": hit["identifier"] ?? NSNull(), "action": hit["action"] ?? NSNull(), "location": wire]
278
+ if let extras = hit["extras"] { payload["extras"] = extras }
279
+ emit("geofence", payload)
280
+ default: break
281
+ }
282
+ engine.recorded()
283
+ if stored {
284
+ if let onUpload { syncer.automatic { _ in onUpload() } }
285
+ else { syncer.automatic() }
286
+ } else { onUpload?() }
287
+ }
288
+ private func persist(_ wire: TrackingJSON, updateRuntime: Bool, location: TrackedLocation? = nil) throws {
289
+ let next = recordedRuntime(wire, location: location)
290
+ let persistence = config.value.object("persistence")
291
+ try store.transaction {
292
+ try store.insert(wire)
293
+ if updateRuntime { try store.save("runtime", next) }
294
+ try store.enforceRetention(days: Int(persistence.number("maxDaysToPersist", 7)), maximum: Int(persistence.number("maxRecordsToPersist", -1)), now: Date(timeIntervalSince1970: clock().wall / 1000))
295
+ }
296
+ if updateRuntime { runtime = next }
297
+ }
298
+ }
@@ -0,0 +1,155 @@
1
+ import Foundation
2
+ import SQLite3
3
+
4
+ final class TrackingStore {
5
+ private var db: OpaquePointer?
6
+ private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
7
+
8
+ init(url: URL) throws {
9
+ var directory = url.deletingLastPathComponent()
10
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
11
+ var resources = URLResourceValues()
12
+ resources.isExcludedFromBackup = true
13
+ try directory.setResourceValues(resources)
14
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
15
+ sqlite3_close(db)
16
+ throw TrackingError("IO_ERROR", "Cannot open tracking database")
17
+ }
18
+ sqlite3_busy_timeout(db, 1000)
19
+ do {
20
+ try execute("PRAGMA journal_mode=WAL")
21
+ try execute("PRAGMA synchronous=FULL")
22
+ try execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
23
+ try execute("CREATE TABLE IF NOT EXISTS records (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT UNIQUE NOT NULL, value TEXT NOT NULL)")
24
+ try execute("CREATE INDEX IF NOT EXISTS records_recorded_at ON records(json_extract(value,'$.recorded_at'), id)")
25
+ try FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path)
26
+ } catch { sqlite3_close(db); db = nil; throw error }
27
+ }
28
+
29
+ deinit { sqlite3_close(db) }
30
+
31
+ private func databaseError(_ message: String) -> TrackingError {
32
+ var error = TrackingError("IO_ERROR", message)
33
+ error.storageFull = sqlite3_errcode(db) == SQLITE_FULL
34
+ return error
35
+ }
36
+
37
+ func execute(_ sql: String) throws {
38
+ guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { throw databaseError("Database operation failed") }
39
+ }
40
+
41
+ private func statement(_ sql: String, _ values: [String] = []) throws -> OpaquePointer {
42
+ var query: OpaquePointer?
43
+ guard sqlite3_prepare_v2(db, sql, -1, &query, nil) == SQLITE_OK, let query else { throw databaseError("Cannot prepare database operation") }
44
+ for (index, value) in values.enumerated() {
45
+ guard sqlite3_bind_text(query, Int32(index + 1), value, -1, transient) == SQLITE_OK else {
46
+ sqlite3_finalize(query)
47
+ throw TrackingError("IO_ERROR", "Cannot bind database value")
48
+ }
49
+ }
50
+ return query
51
+ }
52
+
53
+ private func write(_ sql: String, _ values: [String]) throws {
54
+ let query = try statement(sql, values)
55
+ defer { sqlite3_finalize(query) }
56
+ guard sqlite3_step(query) == SQLITE_DONE else { throw databaseError("Cannot write database value") }
57
+ }
58
+
59
+ func transaction<T>(_ action: () throws -> T) throws -> T {
60
+ try execute("BEGIN IMMEDIATE")
61
+ do {
62
+ let result = try action()
63
+ try execute("COMMIT")
64
+ return result
65
+ } catch { try? execute("ROLLBACK"); throw error }
66
+ }
67
+
68
+ func save(_ key: String, _ value: TrackingJSON) throws {
69
+ try write("INSERT OR REPLACE INTO metadata(key,value) VALUES(?,?)", [key, String(decoding: try jsonData(value), as: UTF8.self)])
70
+ }
71
+
72
+ func load(_ key: String) throws -> TrackingJSON? {
73
+ let query = try statement("SELECT value FROM metadata WHERE key=?", [key])
74
+ defer { sqlite3_finalize(query) }
75
+ let status = sqlite3_step(query)
76
+ if status == SQLITE_DONE { return nil }
77
+ guard status == SQLITE_ROW, let value = sqlite3_column_text(query, 0) else { throw TrackingError("IO_ERROR", "Cannot read database value") }
78
+ return try jsonObject(Data(String(cString: value).utf8))
79
+ }
80
+
81
+ func insert(_ record: TrackingJSON) throws {
82
+ try write("INSERT INTO records(uuid,value) VALUES(?,?)", [try record.requiringString("uuid"), String(decoding: try jsonData(record), as: UTF8.self)])
83
+ }
84
+
85
+ func locations(limit: Int = 0) throws -> [TrackingJSON] {
86
+ try records("SELECT value FROM records ORDER BY json_extract(value,'$.recorded_at') ASC, id ASC" + (limit > 0 ? " LIMIT \(limit)" : ""), [])
87
+ }
88
+
89
+ func locations(events: Set<String>) throws -> [TrackingJSON] {
90
+ let names = events.sorted()
91
+ guard !names.isEmpty else { return [] }
92
+ return try records("SELECT value FROM records WHERE json_extract(value,'$.event') IN (" + names.map { _ in "?" }.joined(separator: ",") + ") ORDER BY json_extract(value,'$.recorded_at') ASC, id ASC", names)
93
+ }
94
+
95
+ func contains(events: Set<String>) throws -> Bool {
96
+ let names = events.sorted()
97
+ guard !names.isEmpty else { return false }
98
+ let query = try statement("SELECT EXISTS(SELECT 1 FROM records WHERE json_extract(value,'$.event') IN (" + names.map { _ in "?" }.joined(separator: ",") + "))", names)
99
+ defer { sqlite3_finalize(query) }
100
+ guard sqlite3_step(query) == SQLITE_ROW else { throw TrackingError("IO_ERROR", "Cannot read queued records") }
101
+ return sqlite3_column_int64(query, 0) != 0
102
+ }
103
+
104
+ private func records(_ sql: String, _ values: [String]) throws -> [TrackingJSON] {
105
+ let query = try statement(sql, values)
106
+ defer { sqlite3_finalize(query) }
107
+ var result: [TrackingJSON] = []
108
+ while true {
109
+ let status = sqlite3_step(query)
110
+ if status == SQLITE_DONE { return result }
111
+ guard status == SQLITE_ROW, let value = sqlite3_column_text(query, 0) else { throw TrackingError("IO_ERROR", "Cannot read queued records") }
112
+ result.append(try jsonObject(Data(String(cString: value).utf8)))
113
+ }
114
+ }
115
+
116
+ var count: Int {
117
+ get throws {
118
+ let query = try statement("SELECT COUNT(*) FROM records")
119
+ defer { sqlite3_finalize(query) }
120
+ guard sqlite3_step(query) == SQLITE_ROW else { throw TrackingError("IO_ERROR", "Cannot count records") }
121
+ return Int(sqlite3_column_int64(query, 0))
122
+ }
123
+ }
124
+
125
+ func count(event: String) throws -> Int {
126
+ let query = try statement("SELECT COUNT(*) FROM records WHERE json_extract(value,'$.event')=?", [event])
127
+ defer { sqlite3_finalize(query) }
128
+ guard sqlite3_step(query) == SQLITE_ROW else { throw TrackingError("IO_ERROR", "Cannot count queued records") }
129
+ return Int(sqlite3_column_int64(query, 0))
130
+ }
131
+
132
+ @discardableResult func delete(_ uuid: String) throws -> Bool {
133
+ try write("DELETE FROM records WHERE uuid=?", [uuid])
134
+ return sqlite3_changes(db) > 0
135
+ }
136
+
137
+ func clear() throws -> Int {
138
+ try execute("DELETE FROM records")
139
+ return Int(sqlite3_changes(db))
140
+ }
141
+
142
+ func prune(days: Int, maximum: Int, now: Date = Date()) throws {
143
+ try transaction { try enforceRetention(days: days, maximum: maximum, now: now) }
144
+ }
145
+
146
+ func enforceRetention(days: Int, maximum: Int, now: Date = Date()) throws {
147
+ if days >= 0 {
148
+ let cutoff = iso(now.addingTimeInterval(-Double(days) * 86400))
149
+ try write("DELETE FROM records WHERE json_extract(value,'$.recorded_at') < ?", [cutoff])
150
+ }
151
+ if maximum > 0 {
152
+ try execute("DELETE FROM records WHERE id NOT IN (SELECT id FROM records ORDER BY json_extract(value,'$.recorded_at') DESC, id DESC LIMIT \(maximum))")
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,43 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ final class DeviceReadings {
5
+ static let shared = DeviceReadings()
6
+ private let lock = NSLock()
7
+ private var identifier: String?
8
+ private var model = "iPhone"
9
+ private var version = ""
10
+ private var level: Float = -1
11
+ private var charging = false
12
+ private var observers: [NSObjectProtocol] = []
13
+ private init() {}
14
+ func start() {
15
+ precondition(Thread.isMainThread)
16
+ UIDevice.current.isBatteryMonitoringEnabled = true
17
+ refresh()
18
+ guard observers.isEmpty else { return }
19
+ for name in [UIDevice.batteryLevelDidChangeNotification, UIDevice.batteryStateDidChangeNotification, UIApplication.didBecomeActiveNotification, UIApplication.protectedDataDidBecomeAvailableNotification] {
20
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.refresh() })
21
+ }
22
+ }
23
+ private func refresh() {
24
+ let device = UIDevice.current
25
+ let identifier = device.identifierForVendor?.uuidString, model = device.model, version = device.systemVersion
26
+ let level = device.batteryLevel, state = device.batteryState
27
+ lock.lock(); defer { lock.unlock() }
28
+ self.identifier = identifier; self.model = model; self.version = version
29
+ self.level = level; charging = state == .charging || state == .full
30
+ }
31
+ func snapshot(event: String) -> (device: TrackingJSON, battery: TrackingJSON) {
32
+ lock.lock(); defer { lock.unlock() }
33
+ var device: TrackingJSON = [:]
34
+ if let identifier { device["device_id"] = identifier }
35
+ if event == "tracking_start" { device.merge(["manufacturer": "Apple", "model": model, "os_version": version, "ram_total_bytes": ProcessInfo.processInfo.physicalMemory]) { _, new in new } }
36
+ if event == "heartbeat" {
37
+ let thermal: String
38
+ switch ProcessInfo.processInfo.thermalState { case .nominal: thermal = "none"; case .fair: thermal = "light"; case .serious: thermal = "severe"; case .critical: thermal = "critical"; @unknown default: thermal = "unknown" }
39
+ device["thermal_status"] = thermal
40
+ }
41
+ return (device, ["level": level, "is_charging": charging])
42
+ }
43
+ }
@@ -0,0 +1,92 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ protocol CheckInCenter {
5
+ func authorized(_ completion: @escaping (Bool) -> Void)
6
+ func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void)
7
+ func cancel()
8
+ func clearDelivered()
9
+ }
10
+
11
+ struct SystemCheckInCenter: CheckInCenter {
12
+ private let center = UNUserNotificationCenter.current()
13
+ func authorized(_ completion: @escaping (Bool) -> Void) {
14
+ center.getNotificationSettings { completion([.authorized, .provisional, .ephemeral].contains($0.authorizationStatus)) }
15
+ }
16
+ func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) { center.add(request, withCompletionHandler: completion) }
17
+ func cancel() { center.removePendingNotificationRequests(withIdentifiers: [CheckInNotifier.identifier]) }
18
+ func clearDelivered() { center.removeDeliveredNotifications(withIdentifiers: [CheckInNotifier.identifier]) }
19
+ }
20
+
21
+ final class CheckInNotifier {
22
+ static let identifier = "com.brickssoft.locationtracking.check-in"
23
+ private let queue: DispatchQueue
24
+ private let clock: () -> TrackingInstant
25
+ private let center: CheckInCenter
26
+ private let warning: (String) -> Void
27
+ private let appName: String
28
+ private var generation = 0
29
+ private var lastChecked: Double?
30
+ private var enabled = false
31
+ private var scheduling = false
32
+ private var latest: (TrackingJSON, Double?)?
33
+ init(queue: DispatchQueue, clock: @escaping () -> TrackingInstant, center: CheckInCenter = SystemCheckInCenter(), appName: String? = nil, warning: @escaping (String) -> Void) {
34
+ self.queue = queue; self.clock = clock; self.center = center; self.warning = warning
35
+ self.appName = appName ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Location tracking"
36
+ }
37
+ func start() { center.clearDelivered(); lastChecked = nil }
38
+ func cancel() {
39
+ generation += 1; enabled = false; latest = nil; lastChecked = nil
40
+ center.cancel()
41
+ }
42
+ func touch(_ settings: TrackingJSON, deadlineElapsed: Double?, force: Bool = false) {
43
+ guard settings.flag("enabled") else { cancel(); return }
44
+ let deadlineChanged = latest.map { $0.1 != deadlineElapsed } ?? false
45
+ enabled = true
46
+ latest = (settings, deadlineElapsed)
47
+ if force || deadlineChanged { generation += 1 }
48
+ if force { lastChecked = nil }
49
+ drain()
50
+ }
51
+ private func drain() {
52
+ guard enabled, !scheduling, let (settings, deadline) = latest else { return }
53
+ let now = clock()
54
+ let delay = settings.number("delay", 600)
55
+ guard deadline == nil || now.elapsed + delay * 1000 <= deadline! else { cancel(); return }
56
+ if let lastChecked, now.elapsed - lastChecked < 60000 { return }
57
+ let previous = lastChecked
58
+ let mark = now.elapsed
59
+ lastChecked = mark
60
+ scheduling = true
61
+ let captured = generation
62
+ center.authorized { allowed in
63
+ self.queue.async {
64
+ guard captured == self.generation, self.enabled, let (settings, deadline) = self.latest else {
65
+ if self.lastChecked == mark { self.lastChecked = previous }
66
+ self.scheduling = false; self.drain(); return
67
+ }
68
+ let now = self.clock()
69
+ let delay = settings.number("delay", 600)
70
+ guard allowed, deadline == nil || now.elapsed + delay * 1000 <= deadline! else {
71
+ self.scheduling = false; self.center.cancel(); return
72
+ }
73
+ let content = UNMutableNotificationContent()
74
+ let title = settings.text("title")
75
+ content.title = title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? self.appName : title
76
+ content.body = settings.text("text", "Location tracking is paused. Tap to resume.")
77
+ content.sound = .default
78
+ let request = UNNotificationRequest(identifier: Self.identifier, content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: max(1, delay), repeats: false))
79
+ self.center.add(request) { error in
80
+ self.queue.async {
81
+ self.scheduling = false
82
+ if captured == self.generation && self.enabled {
83
+ if error == nil { self.lastChecked = self.clock().elapsed }
84
+ else { self.warning("Check-in notification could not be scheduled") }
85
+ } else if !self.enabled { self.center.cancel() }
86
+ if captured != self.generation { self.drain() }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }