@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,122 @@
1
+ import Foundation
2
+
3
+ struct ActivitySample: Equatable {
4
+ var type: String
5
+ var confidence: Int
6
+ var json: TrackingJSON { ["type": type, "confidence": confidence] }
7
+ static func map(automotive: Bool, cycling: Bool, running: Bool, walking: Bool, stationary: Bool, confidence: Int) -> ActivitySample {
8
+ ActivitySample(type: automotive ? "in_vehicle" : cycling ? "on_bicycle" : running ? "running" : walking ? "walking" : stationary ? "still" : "unknown", confidence: confidence)
9
+ }
10
+ }
11
+
12
+ struct MotionSettings: Equatable {
13
+ var stationaryRadius = 25.0
14
+ var minimumConfidence = 75
15
+ var motionTriggerDelay = 0.0
16
+ var stopTimeout = 300000.0
17
+ var disableStopDetection = false
18
+ var trackingAccuracyThreshold = 100.0
19
+ static func from(_ config: TrackingJSON) -> MotionSettings {
20
+ let geo = config.object("geolocation"), activity = config.object("activity")
21
+ return MotionSettings(stationaryRadius: max(0, geo.number("stationaryRadius", 25)), minimumConfidence: Int(activity.number("minimumActivityRecognitionConfidence", 75)), motionTriggerDelay: max(0, activity.number("motionTriggerDelay")), stopTimeout: max(1, geo.number("stopTimeout", 5)) * 60000, disableStopDetection: activity.flag("disableStopDetection"), trackingAccuracyThreshold: geo.object("filter").number("trackingAccuracyThreshold", 100))
22
+ }
23
+ }
24
+
25
+ enum MotionAction: Equatable {
26
+ case enterMoving(TrackedLocation?)
27
+ case enterStationary(TrackedLocation?, automatic: Bool)
28
+ case startStopTimer(Double)
29
+ case cancelStopTimer
30
+ case startMotionTrigger(Double)
31
+ case cancelMotionTrigger
32
+ }
33
+
34
+ final class MotionStateMachine {
35
+ private(set) var settings: MotionSettings
36
+ private(set) var isMoving = false
37
+ private(set) var anchor: TrackedLocation?
38
+ private(set) var motionAnchor: TrackedLocation?
39
+ private(set) var isStopTimerArmed = false
40
+ private(set) var isMotionTriggerArmed = false
41
+ static let movingActivities: Set<String> = ["walking", "running", "on_foot", "on_bicycle", "in_vehicle"]
42
+ init(_ settings: MotionSettings = MotionSettings()) { self.settings = settings }
43
+ func reset(isMoving: Bool = false, anchor: TrackedLocation? = nil) {
44
+ self.isMoving = isMoving
45
+ self.anchor = isMoving ? nil : anchor
46
+ motionAnchor = isMoving ? anchor : nil
47
+ isStopTimerArmed = false; isMotionTriggerArmed = false
48
+ }
49
+ func offerAnchor(_ location: TrackedLocation?) { if !isMoving && anchor == nil { anchor = location } }
50
+ func setAnchor(_ location: TrackedLocation) { if !isMoving { anchor = location } }
51
+ func updateSettings(_ new: MotionSettings) -> [MotionAction] {
52
+ let old = settings; settings = new
53
+ if !isMoving {
54
+ if isMotionTriggerArmed && new.motionTriggerDelay != old.motionTriggerDelay { isMotionTriggerArmed = false; return [.cancelMotionTrigger] }
55
+ return []
56
+ }
57
+ if new.disableStopDetection { return cancelStopTimer() }
58
+ if !isStopTimerArmed || new.stopTimeout != old.stopTimeout { return restartStopTimer() }
59
+ return []
60
+ }
61
+ func onLocation(_ fix: TrackedLocation) -> [MotionAction] {
62
+ if !isMoving {
63
+ guard let current = anchor else { anchor = fix; return [] }
64
+ if Geo.distance(current, fix) - fix.accuracyMeters > settings.stationaryRadius { return isAccurateEnough(fix) ? enterMoving(fix) : [] }
65
+ if fix.accuracyMeters < current.accuracyMeters { anchor = fix }
66
+ return []
67
+ }
68
+ guard let reference = motionAnchor else { motionAnchor = fix; return ensureStopTimer() }
69
+ if Geo.distance(reference, fix) > max(settings.stationaryRadius, fix.accuracyMeters) { motionAnchor = fix; return restartStopTimer() }
70
+ return ensureStopTimer()
71
+ }
72
+ func onActivity(_ sample: ActivitySample) -> [MotionAction] {
73
+ guard sample.confidence >= settings.minimumConfidence else { return [] }
74
+ let moving = Self.movingActivities.contains(sample.type)
75
+ if isMoving {
76
+ if moving { return restartStopTimer() }
77
+ return sample.type == "still" ? ensureStopTimer() : []
78
+ }
79
+ if moving && settings.motionTriggerDelay <= 0 { return enterMoving(nil) }
80
+ if moving && !isMotionTriggerArmed { isMotionTriggerArmed = true; return [.startMotionTrigger(settings.motionTriggerDelay)] }
81
+ if sample.type == "still" && isMotionTriggerArmed { isMotionTriggerArmed = false; return [.cancelMotionTrigger] }
82
+ return []
83
+ }
84
+ func onMotionTriggerElapsed() -> [MotionAction] {
85
+ let armed = isMotionTriggerArmed; isMotionTriggerArmed = false
86
+ return armed && !isMoving ? enterMoving(nil) : []
87
+ }
88
+ func onStopTimeout(_ location: TrackedLocation?) -> [MotionAction] {
89
+ let armed = isStopTimerArmed; isStopTimerArmed = false
90
+ return armed && isMoving ? enterStationary(location, automatic: true) : []
91
+ }
92
+ func force(_ moving: Bool, _ location: TrackedLocation?) -> [MotionAction] {
93
+ if moving { return isMoving ? restartStopTimer() : enterMoving(location) }
94
+ return isMoving ? enterStationary(location, automatic: false) : []
95
+ }
96
+ func isAccurateEnough(_ fix: TrackedLocation) -> Bool { settings.trackingAccuracyThreshold <= 0 || fix.accuracyMeters <= settings.trackingAccuracyThreshold }
97
+ private func enterMoving(_ fix: TrackedLocation?) -> [MotionAction] {
98
+ var actions: [MotionAction] = []
99
+ if isMotionTriggerArmed { isMotionTriggerArmed = false; actions.append(.cancelMotionTrigger) }
100
+ isMoving = true; anchor = nil; motionAnchor = fix
101
+ actions.append(.enterMoving(fix)); actions += restartStopTimer()
102
+ return actions
103
+ }
104
+ private func enterStationary(_ fix: TrackedLocation?, automatic: Bool) -> [MotionAction] {
105
+ var actions = cancelStopTimer()
106
+ if isMotionTriggerArmed { isMotionTriggerArmed = false; actions.append(.cancelMotionTrigger) }
107
+ isMoving = false; anchor = fix; motionAnchor = nil
108
+ actions.append(.enterStationary(fix, automatic: automatic))
109
+ return actions
110
+ }
111
+ private func restartStopTimer() -> [MotionAction] {
112
+ guard !settings.disableStopDetection else { return [] }
113
+ isStopTimerArmed = true
114
+ return [.startStopTimer(settings.stopTimeout)]
115
+ }
116
+ private func ensureStopTimer() -> [MotionAction] { isStopTimerArmed ? [] : restartStopTimer() }
117
+ private func cancelStopTimer() -> [MotionAction] {
118
+ guard isStopTimerArmed else { return [] }
119
+ isStopTimerArmed = false
120
+ return [.cancelStopTimer]
121
+ }
122
+ }
@@ -0,0 +1,29 @@
1
+ import Foundation
2
+
3
+ struct TrackingInstant: Equatable {
4
+ var wall: Double
5
+ var elapsed: Double
6
+ var json: TrackingJSON { ["wall": wall, "elapsed": elapsed] }
7
+ static func from(_ json: TrackingJSON) -> TrackingInstant? {
8
+ guard let wall = json["wall"] as? Double, let elapsed = json["elapsed"] as? Double else { return nil }
9
+ return TrackingInstant(wall: wall, elapsed: elapsed)
10
+ }
11
+ func continues(_ previous: TrackingInstant) -> Bool {
12
+ elapsed >= previous.elapsed && abs((wall - elapsed) - (previous.wall - previous.elapsed)) < 2000
13
+ }
14
+ }
15
+
16
+ struct SessionDeadline {
17
+ var startedAt: Double
18
+ var startedElapsed: Double
19
+ init(startedAt: Double, startedElapsed: Double?, checkpoint: TrackingInstant?, now: TrackingInstant) {
20
+ self.startedAt = startedAt
21
+ if let startedElapsed, let checkpoint, now.continues(checkpoint) { self.startedElapsed = startedElapsed }
22
+ else { self.startedElapsed = now.elapsed - (now.wall - startedAt) }
23
+ }
24
+ init(now: TrackingInstant) { startedAt = now.wall; startedElapsed = now.elapsed }
25
+ func remaining(minutes: Double, now: TrackingInstant) -> Double? {
26
+ minutes > 0 ? minutes * 60000 - (now.elapsed - startedElapsed) : nil
27
+ }
28
+ func elapsedDeadline(minutes: Double) -> Double? { minutes > 0 ? startedElapsed + minutes * 60000 : nil }
29
+ }
@@ -0,0 +1,344 @@
1
+ import Foundation
2
+
3
+ final class TrackingEngine {
4
+ private unowned let core: TrackingCore
5
+ private(set) var running = false
6
+ private(set) var provider: TrackingJSON = [:]
7
+ private var foreground = false
8
+ private var mode = "location"
9
+ private(set) var sessionID = UUID().uuidString
10
+ private var deadline: SessionDeadline?
11
+ private var motion = MotionStateMachine()
12
+ private let processor = LocationProcessor()
13
+ let odometer = Odometer()
14
+ private var lastFix: TrackedLocation?
15
+ private var backendLocation: TrackedLocation?
16
+ private var lastRecorded: TrackedLocation?
17
+ private var motionRecorded = false
18
+ private var lastLocationPersisted: Double?
19
+ private var lastActivityPersisted: Double?
20
+ private var lastActivityType: String?
21
+ private var registrationFailures = 0
22
+ private var armedHeartbeat: HeartbeatWindow?
23
+ private var lastAttempt: Double?
24
+ private var checkInConfig: Data?
25
+ private var assertion: ExecutionAssertion?
26
+ var geofences: GeofenceManager?
27
+ private var geofenceDemand = false
28
+ lazy var timers = TrackingTimers(queue: core.queue, now: { [unowned self] in self.core.clock().elapsed }, automatic: core.automaticTimers)
29
+ lazy var notifier = CheckInNotifier(queue: core.queue, clock: core.clock) { [weak self] in self?.core.log.write("warn", $0) }
30
+ init(core: TrackingCore) {
31
+ self.core = core
32
+ if let at = core.runtime["trackingStartedAt"] as? Double {
33
+ deadline = SessionDeadline(startedAt: at, startedElapsed: core.runtime["trackingStartedElapsed"] as? Double, checkpoint: TrackingInstant.from(core.runtime.object("clockCheckpoint")), now: core.clock())
34
+ }
35
+ }
36
+ private var config: TrackingJSON { core.config.value }
37
+ private var geo: TrackingJSON { config.object("geolocation") }
38
+ private var bestKnown: TrackedLocation? {
39
+ let threshold = geo.object("filter").number("trackingAccuracyThreshold", 100)
40
+ let vetted = backendLocation.flatMap { threshold <= 0 || $0.accuracyMeters <= threshold ? $0 : nil }
41
+ return [lastFix, TrackedLocation.from(core.runtime.object("lastLocation")), vetted].compactMap { $0 }.max { $0.isOlder(than: $1) } ?? backendLocation
42
+ }
43
+ func offerLastLocation(_ location: TrackedLocation?) { backendLocation = location }
44
+ func context(provider: TrackingJSON, foreground: Bool) { self.provider = provider; self.foreground = foreground }
45
+ func restore() throws {
46
+ guard core.runtime.flag("enabled") else { applyProfile(force: true); notifier.cancel(); geofences?.releaseStale(); return }
47
+ guard !running else { return }
48
+ if let remaining = deadline?.remaining(minutes: geo.number("stopAfterElapsedMinutes"), now: core.clock()), remaining <= 0 { try stop(reason: "stop_after_elapsed"); return }
49
+ guard provider["enabled"] as? Bool != false else { scheduleElapsedStop(); return }
50
+ guard provider.text("permission") == "always" || provider.text("permission") == "when_in_use" else { try stop(reason: "permission_denied"); return }
51
+ if deadline == nil { deadline = SessionDeadline(now: core.clock()) }
52
+ try saveSession(enabled: true, mode: core.runtime.text("trackingMode", "location"))
53
+ guard foreground || provider.text("permission") == "always" else { scheduleElapsedStop(); return }
54
+ activate(mode: core.runtime.text("trackingMode", "location"), reason: "restore")
55
+ }
56
+ func start(mode: String = "location") throws {
57
+ if running && self.mode == mode { return }
58
+ guard provider.text("permission") == "always" || provider.text("permission") == "when_in_use" else { throw TrackingError("PERMISSION_DENIED", "Location permission is required") }
59
+ guard foreground || provider.text("permission") == "always" else { throw TrackingError("PERMISSION_DENIED", "Start When In Use tracking while the app is in the foreground") }
60
+ let switching = running
61
+ if switching && self.mode == "location" && motion.isMoving { recordMotion(false, bestKnown) }
62
+ deadline = SessionDeadline(now: core.clock())
63
+ try saveSession(enabled: true, mode: mode)
64
+ if switching { configure(mode: mode); submit("tracking_start", bestKnown, reason: mode == "location" ? "start" : "start_geofences") }
65
+ else {
66
+ activate(mode: mode, reason: mode == "location" ? "start" : "start_geofences")
67
+ core.emit("enabledchange", ["enabled": true, "reason": mode == "location" ? "start" : "start_geofences"])
68
+ }
69
+ }
70
+ private func saveSession(enabled: Bool, mode: String) throws {
71
+ guard let deadline else { return }
72
+ try core.updateRuntime(["enabled": enabled, "trackingMode": mode, "isMoving": false, "trackingStartedAt": deadline.startedAt, "trackingStartedElapsed": deadline.startedElapsed])
73
+ }
74
+ private func activate(mode: String, reason: String) {
75
+ running = true; core.syncer.tracking = true
76
+ processor.reset(); lastAttempt = nil; armedHeartbeat = nil
77
+ notifier.start()
78
+ configure(mode: mode)
79
+ submit("tracking_start", bestKnown, reason: reason)
80
+ }
81
+ private func configure(mode: String) {
82
+ cancelInitialFix(); timers.cancelAll()
83
+ sessionID = UUID().uuidString
84
+ self.mode = mode
85
+ motion = MotionStateMachine(MotionSettings.from(config))
86
+ motionRecorded = false; lastRecorded = nil; lastActivityType = nil; lastActivityPersisted = nil; lastLocationPersisted = nil; registrationFailures = 0
87
+ applyProfile()
88
+ scheduleElapsedStop()
89
+ if mode == "location" { initialFixRequest() }
90
+ rescheduleHeartbeat(force: true)
91
+ touchCheckIn(force: true)
92
+ updateGeofenceAccess()
93
+ geofences?.onTrackingStarted(always: provider.text("permission") == "always")
94
+ }
95
+ func stop(reason: String = "stop") throws {
96
+ notifier.cancel()
97
+ guard running || core.runtime.flag("enabled") else { return }
98
+ running = false; timers.cancelAll(); armedHeartbeat = nil; lastAttempt = nil
99
+ assertion?.finish(); assertion = nil
100
+ geofences?.onTrackingStopped()
101
+ cancelInitialFix()
102
+ submit("tracking_stop", bestKnown, reason: reason)
103
+ let stopped: TrackingJSON = ["enabled": false, "isMoving": false, "lastStopReason": reason, "lastStopAt": iso(Date(timeIntervalSince1970: core.clock().wall / 1000))]
104
+ do { try core.updateRuntime(stopped) }
105
+ catch {
106
+ core.runtime.merge(stopped) { _, new in new }
107
+ applyProfile(); core.syncer.tracking = false
108
+ core.emit("enabledchange", ["enabled": false, "reason": reason])
109
+ throw error
110
+ }
111
+ applyProfile(); core.syncer.tracking = false; core.syncer.automatic()
112
+ core.emit("enabledchange", ["enabled": false, "reason": reason])
113
+ }
114
+ func terminate() { if config.object("app").flag("stopOnTerminate", true) { do { try stop(reason: "terminate") } catch { core.log.write("error", "Could not persist termination intent") } } }
115
+ func active(provider: TrackingJSON, foreground: Bool) {
116
+ context(provider: provider, foreground: foreground)
117
+ do { try restore() } catch { core.log.write("error", "Tracking restore failed") }
118
+ if running { applyProfile(force: true); timers.fireDue(); rescheduleHeartbeat(force: true); touchCheckIn() }
119
+ }
120
+ func clockChanged() {
121
+ update([:]); scheduleElapsedStop(); rescheduleHeartbeat(force: true); timers.fireDue()
122
+ }
123
+ func configChanged() {
124
+ if !config.object("notification").object("resume").flag("enabled") { notifier.cancel() }
125
+ guard running else {
126
+ if core.runtime.flag("enabled") { scheduleElapsedStop() }
127
+ return
128
+ }
129
+ execute(motion.updateSettings(MotionSettings.from(config)))
130
+ guard running else { return }
131
+ applyProfile(); scheduleElapsedStop(); rescheduleHeartbeat(force: true); touchCheckIn()
132
+ timers.fireDue()
133
+ }
134
+ func providerChanged(_ value: TrackingJSON) {
135
+ let previous = provider
136
+ guard !NSDictionary(dictionary: previous).isEqual(to: value) else { return }
137
+ provider = value
138
+ core.emit("providerchange", value)
139
+ guard running else {
140
+ if core.runtime.flag("enabled") { do { try restore() } catch { core.log.write("error", "Tracking restore failed") } }
141
+ return
142
+ }
143
+ submit("providerchange", bestKnown)
144
+ if value.flag("enabled") && value.text("permission") == "denied" { try? stop(reason: "permission_denied"); return }
145
+ if value.flag("enabled") && (!previous.flag("enabled") || previous.text("permission") != value.text("permission")) { applyProfile(force: true) }
146
+ if previous.text("accuracy") != value.text("accuracy") { updateGeofenceAccess() }
147
+ if previous.text("permission") != value.text("permission") { geofences?.onTrackingStarted(always: value.text("permission") == "always") }
148
+ }
149
+ func changePace(_ moving: Bool) {
150
+ guard running, mode == "location" else { return }
151
+ execute(motion.force(moving, motion.isMoving == moving ? nil : bestKnown))
152
+ }
153
+ func receive(_ batch: [TrackedLocation], session: String? = nil) {
154
+ guard running, session == nil || session == sessionID else { return }
155
+ registrationFailures = 0
156
+ if let newest = batch.max(by: { $0.isOlder(than: $1) }) { backendLocation = newest }
157
+ if !batch.isEmpty { touchCheckIn() }
158
+ for raw in batch {
159
+ guard running else { return }
160
+ guard case .accepted(var fix) = processor.process(raw, filter: geo.object("filter")) else { continue }
161
+ fix.session = core.runtime["trackingStartedAt"] as? Double
162
+ lastFix = fix
163
+ geofences?.onLocation(fix)
164
+ guard running else { return }
165
+ if mode != "location" {
166
+ let delta = odometer.onLocation(fix, runtime: core.runtime, threshold: geo.object("filter").number("odometerAccuracyThreshold", 20))
167
+ if delta > 0 { update(["odometer": core.odometer + delta]) }
168
+ rememberLocation(fix)
169
+ continue
170
+ }
171
+ let wasMoving = motion.isMoving
172
+ let oldAnchor = motion.anchor
173
+ let actions = motion.onLocation(fix)
174
+ if !wasMoving && !motion.isMoving {
175
+ if let anchor = motion.anchor, anchor != oldAnchor { rememberLocation(anchor, force: true) }
176
+ continue
177
+ }
178
+ let delta = odometer.onLocation(fix, runtime: core.runtime, threshold: geo.object("filter").number("odometerAccuracyThreshold", 20))
179
+ if delta > 0 { update(["odometer": core.odometer + delta]) }
180
+ execute(actions)
181
+ guard running, wasMoving else { continue }
182
+ if ElasticDistance.shouldRecord(lastRecorded, fix, geo) { submit("location", fix); lastRecorded = fix; lastLocationPersisted = core.clock().elapsed }
183
+ else { rememberLocation(fix) }
184
+ }
185
+ timers.fireDue()
186
+ }
187
+ func initialFix(_ raw: TrackedLocation?) {
188
+ guard running, mode == "location", !motionRecorded else { return }
189
+ if let raw, case .accepted(var fix) = processor.process(raw, filter: geo.object("filter")) {
190
+ fix.session = core.runtime["trackingStartedAt"] as? Double
191
+ if lastFix == nil || !fix.isOlder(than: lastFix!) { lastFix = fix }
192
+ motion.setAnchor(fix)
193
+ }
194
+ let known = bestKnown
195
+ motion.offerAnchor(known)
196
+ recordMotion(false, known)
197
+ }
198
+ func activity(_ sample: ActivitySample, session: String? = nil) {
199
+ guard running, session == nil || session == sessionID, !config.object("activity").flag("disableMotionActivityUpdates"), sample.confidence >= Int(config.object("activity").number("minimumActivityRecognitionConfidence", 75)) else { return }
200
+ let now = core.clock().elapsed
201
+ let current = core.runtime.object("activity")
202
+ if current.text("type") != sample.type || lastActivityPersisted == nil || now - lastActivityPersisted! >= 60000 {
203
+ update(["activity": sample.json]); lastActivityPersisted = now
204
+ }
205
+ if lastActivityType != sample.type { lastActivityType = sample.type; core.emit("activitychange", ["activity": sample.type, "confidence": sample.confidence]) }
206
+ if mode == "location" { execute(motion.onActivity(sample)) }
207
+ timers.fireDue()
208
+ }
209
+ func registrationFailed(_ error: TrackingError, session: String? = nil) {
210
+ guard running, session == nil || session == sessionID else { return }
211
+ registrationFailures += 1
212
+ let delay = min(300000, 10000 * pow(2, Double(min(10, registrationFailures - 1))))
213
+ core.emit("locationerror", ["code": error.code, "message": error.message, "backend": PluginInfo.backend, "retryInMs": delay])
214
+ timers.schedule("request_retry", after: delay) { [weak self] in guard let self, self.running else { return }; self.applyProfile(force: true) }
215
+ }
216
+ private func execute(_ actions: [MotionAction]) {
217
+ for action in actions {
218
+ guard running else { return }
219
+ switch action {
220
+ case .startStopTimer(let delay): timers.schedule("stop_timeout", after: delay) { [weak self] in guard let self, self.running else { return }; self.execute(self.motion.onStopTimeout(self.bestKnown)) }
221
+ case .cancelStopTimer: timers.cancel("stop_timeout")
222
+ case .startMotionTrigger(let delay): timers.schedule("motion_trigger", after: delay) { [weak self] in guard let self, self.running else { return }; self.execute(self.motion.onMotionTriggerElapsed()) }
223
+ case .cancelMotionTrigger: timers.cancel("motion_trigger")
224
+ case .enterMoving(let location): recordMotion(true, location ?? bestKnown); applyProfile()
225
+ case .enterStationary(let location, let automatic):
226
+ recordMotion(false, location)
227
+ if automatic && geo.flag("stopOnStationary") { try? stop(reason: "stop_on_stationary") }
228
+ else { applyProfile() }
229
+ }
230
+ }
231
+ }
232
+ private func recordMotion(_ moving: Bool, _ fix: TrackedLocation?) {
233
+ update(["isMoving": moving]); submit("motionchange", fix)
234
+ motionRecorded = true
235
+ if let fix { lastRecorded = fix; lastLocationPersisted = core.clock().elapsed }
236
+ }
237
+ private func rememberLocation(_ fix: TrackedLocation, force: Bool = false) {
238
+ let now = core.clock().elapsed
239
+ guard force || lastLocationPersisted == nil || now - lastLocationPersisted! >= 10000 else { return }
240
+ lastLocationPersisted = now; update(["lastLocation": fix.json])
241
+ }
242
+ private func update(_ values: TrackingJSON) {
243
+ do { try core.updateRuntime(values) }
244
+ catch { core.runtime.merge(values) { _, new in new }; core.log.write("error", "Tracking state could not be persisted") }
245
+ }
246
+ private func applyProfile(force: Bool = false) {
247
+ guard let source = core.routeSource else { return }
248
+ let profile = RouteProfile(enabled: running && (mode == "location" || geofenceDemand), moving: mode == "location" ? motion.isMoving : geofenceDemand, accuracy: geo.text("desiredAccuracy", "high"), activityEnabled: mode == "location" && !config.object("activity").flag("disableMotionActivityUpdates"), sessionID: sessionID)
249
+ DispatchQueue.main.async { source.apply(profile, force: force) }
250
+ }
251
+ private func initialFixRequest() {
252
+ guard core.routeSource != nil else { return }
253
+ let id = sessionID
254
+ let options: TrackingJSON = ["desiredAccuracy": geo.text("desiredAccuracy", "high"), "timeout": geo.number("locationTimeout", 30000), "samples": 3, "_rejectMock": geo.object("filter").flag("rejectMockLocations")]
255
+ DispatchQueue.main.async { [weak self] in
256
+ guard let self else { return }
257
+ self.core.positions.request(id: "route-" + id, options: options, watch: false) { [weak self] result in
258
+ guard let self else { return }
259
+ self.core.queue.async { [weak self] in guard let self, self.sessionID == id else { return }; self.initialFix((try? result.get()).map { TrackedLocation($0, receivedAt: self.core.clock()) }) }
260
+ }
261
+ }
262
+ }
263
+ private func cancelInitialFix() {
264
+ let id = sessionID, positions = core.positions
265
+ DispatchQueue.main.async { positions.clear("route-" + id) }
266
+ }
267
+ private func scheduleElapsedStop() {
268
+ timers.cancel("elapsed_stop")
269
+ guard core.runtime.flag("enabled"), let remaining = deadline?.remaining(minutes: geo.number("stopAfterElapsedMinutes"), now: core.clock()) else { return }
270
+ timers.schedule("elapsed_stop", after: remaining) { [weak self] in try? self?.stop(reason: "stop_after_elapsed") }
271
+ }
272
+ private func touchCheckIn(force: Bool = false) {
273
+ guard running else { return }
274
+ let settings = config.object("notification").object("resume")
275
+ let encoded = try? jsonData(settings)
276
+ let changed = encoded != checkInConfig
277
+ checkInConfig = encoded
278
+ notifier.touch(settings, deadlineElapsed: deadline?.elapsedDeadline(minutes: geo.number("stopAfterElapsedMinutes")), force: force || changed)
279
+ }
280
+ private func window() -> HeartbeatWindow {
281
+ let now = core.clock(), hb = config.object("heartbeat")
282
+ return HeartbeatWindow.compute(lastRecordAt: timestamp(core.runtime["lastRecordAt"]), lastRecordElapsed: core.runtime["lastRecordElapsed"] as? Double, sameBoot: true, now: now.wall, nowElapsed: now.elapsed, minInterval: hb.number("minInterval", 180), maxInterval: hb.number("maxInterval", 300), enabled: running && hb.flag("enabled", true), noRecordBaseElapsed: deadline?.startedElapsed, lastAttemptElapsed: lastAttempt, trackingStartedAt: deadline?.startedAt)
283
+ }
284
+ func recorded() { if running { rescheduleHeartbeat(); touchCheckIn() } }
285
+ private func rescheduleHeartbeat(force: Bool = false) {
286
+ let next = window()
287
+ guard next.enabled else { timers.cancel("heartbeat"); armedHeartbeat = nil; return }
288
+ guard force || next.needsRearm(armedHeartbeat) else { return }
289
+ armedHeartbeat = next
290
+ timers.schedule("heartbeat", after: next.dueElapsed - core.clock().elapsed) { [weak self] in self?.heartbeat() }
291
+ }
292
+ func heartbeat() {
293
+ guard running else { return }
294
+ if let remaining = deadline?.remaining(minutes: geo.number("stopAfterElapsedMinutes"), now: core.clock()), remaining <= 0 { try? stop(reason: "stop_after_elapsed"); return }
295
+ let current = window(), now = core.clock()
296
+ guard current.enabled else { return }
297
+ guard current.shouldFire(now.elapsed) else { rescheduleHeartbeat(force: true); return }
298
+ if now.elapsed > current.deadlineElapsed + 1000 { core.log.write("warn", "Heartbeat arrived after heartbeat.maxInterval") }
299
+ lastAttempt = now.elapsed
300
+ var wire = core.makeRecord(event: "heartbeat", fix: bestKnown)
301
+ let next = HeartbeatWindow.compute(lastRecordAt: now.wall, lastRecordElapsed: now.elapsed, sameBoot: true, now: now.wall, nowElapsed: now.elapsed, minInterval: config.object("heartbeat").number("minInterval", 180), maxInterval: config.object("heartbeat").number("maxInterval", 300))
302
+ wire["heartbeat"] = ["strategy": "timer", "min_interval": config.object("heartbeat").number("minInterval", 180), "max_interval": config.object("heartbeat").number("maxInterval", 300), "next_at": next.nextHeartbeatAt.map { iso(Date(timeIntervalSince1970: $0 / 1000)) } as Any? ?? NSNull(), "battery_exempt": false, "device_idle": false]
303
+ assertion?.finish()
304
+ let upload = ExecutionAssertion(); assertion = upload
305
+ upload.start(seconds: config.object("heartbeat").number("uploadKeepAwake", 10), queue: core.queue)
306
+ core.deliver(wire, location: bestKnown) { upload.finish() }
307
+ rescheduleHeartbeat(force: true)
308
+ }
309
+ func heartbeatStatus() -> TrackingJSON {
310
+ let hb = config.object("heartbeat"), current = window()
311
+ return ["enabled": hb.flag("enabled", true), "minInterval": hb.number("minInterval", 180), "maxInterval": hb.number("maxInterval", 300), "lastRecordAt": core.runtime["lastRecordAt"] ?? NSNull(), "lastHeartbeatAt": core.runtime["lastHeartbeatAt"] ?? NSNull(), "nextHeartbeatAt": current.nextHeartbeatAt.map { iso(Date(timeIntervalSince1970: $0 / 1000)) } as Any? ?? NSNull(), "strategy": current.enabled ? "timer" : "disabled", "canScheduleExactAlarms": false, "isIgnoringBatteryOptimizations": false, "isDeviceIdleMode": false, "isPowerSaveMode": ProcessInfo.processInfo.isLowPowerModeEnabled, "pendingHeartbeats": (try? core.store.count(event: "heartbeat")) ?? 0]
312
+ }
313
+ private func timestamp(_ value: Any?) -> Double? {
314
+ guard let text = value as? String else { return nil }
315
+ return isoDate(text).map { $0.timeIntervalSince1970 * 1000 }
316
+ }
317
+ func geofenceDemandChanged(_ needs: Bool) {
318
+ guard geofenceDemand != needs else { return }
319
+ geofenceDemand = needs
320
+ if running { applyProfile() }
321
+ }
322
+ func recordGeofence(_ spec: GeofenceSpec, _ action: GeofenceAction, _ location: TrackedLocation?) {
323
+ var wire = core.makeRecord(event: "geofence", fix: location)
324
+ var hit: TrackingJSON = ["identifier": spec.identifier, "action": action.rawValue]
325
+ if let extras = spec.extras { hit["extras"] = extras }
326
+ wire["geofence"] = hit
327
+ core.deliver(wire, location: location)
328
+ }
329
+ private func updateGeofenceAccess() { geofences?.setSuspended(provider.text("accuracy") == "approximate") }
330
+ func geofenceFailures(_ failures: [MonitorFailure]) {
331
+ for failure in failures {
332
+ core.log.write("error", failure.message + " (" + failure.code + ")")
333
+ core.emit("locationerror", ["code": failure.code, "message": failure.message, "backend": PluginInfo.backend, "retryInMs": NSNull()])
334
+ }
335
+ }
336
+ func geofencesBlocked() {
337
+ core.emit("locationerror", ["code": "PERMISSION_DENIED", "message": "Geofences need precise location; stored geofences stay inactive while location accuracy is approximate", "backend": PluginInfo.backend, "retryInMs": NSNull()])
338
+ }
339
+ private func submit(_ event: String, _ location: TrackedLocation?, reason: String? = nil) {
340
+ var wire = core.makeRecord(event: event, fix: location)
341
+ if let reason { wire["reason"] = reason }
342
+ core.deliver(wire, location: location)
343
+ }
344
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ final class TrackingTimers {
4
+ private struct Entry { let at: Double; let action: () -> Void }
5
+ private let queue: DispatchQueue
6
+ private let now: () -> Double
7
+ private let automatic: Bool
8
+ private var entries: [String: Entry] = [:]
9
+ private var source: DispatchSourceTimer?
10
+ init(queue: DispatchQueue, now: @escaping () -> Double, automatic: Bool = true) { self.queue = queue; self.now = now; self.automatic = automatic }
11
+ deinit { source?.cancel() }
12
+ func schedule(_ kind: String, after delay: Double, _ action: @escaping () -> Void) {
13
+ entries[kind] = Entry(at: now() + max(0, delay), action: action)
14
+ arm()
15
+ }
16
+ func cancel(_ kind: String) { entries.removeValue(forKey: kind); arm() }
17
+ func cancelAll() { entries.removeAll(); source?.cancel(); source = nil }
18
+ func fireDue() {
19
+ while let due = entries.min(by: { $0.value.at < $1.value.at }), due.value.at <= now() {
20
+ entries.removeValue(forKey: due.key)
21
+ due.value.action()
22
+ }
23
+ arm()
24
+ }
25
+ private func arm() {
26
+ guard automatic else { return }
27
+ guard let first = entries.values.map(\.at).min() else { source?.cancel(); source = nil; return }
28
+ if source == nil {
29
+ let timer = DispatchSource.makeTimerSource(queue: queue)
30
+ timer.setEventHandler { [weak self] in self?.fireDue() }
31
+ source = timer; timer.resume()
32
+ }
33
+ source?.schedule(deadline: .now() + min(86400, max(0.001, (first - now()) / 1000)), leeway: .milliseconds(1000))
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ final class DwellTracker {
4
+ private struct Pending { let deadline: Double; let token: Double; let sequence: Int }
5
+ private let timers: TrackingTimers
6
+ private let now: () -> Double
7
+ private let onDue: (String, Double) -> Void
8
+ private var pending: [String: Pending] = [:]
9
+ private var sequence = 0
10
+ init(queue: DispatchQueue, now: @escaping () -> Double, automatic: Bool = true, onDue: @escaping (String, Double) -> Void) {
11
+ self.now = now; self.onDue = onDue
12
+ timers = TrackingTimers(queue: queue, now: now, automatic: automatic)
13
+ }
14
+ func arm(_ id: String, deadline: Double, token: Double) {
15
+ sequence += 1
16
+ let current = sequence
17
+ pending[id] = Pending(deadline: deadline, token: token, sequence: current)
18
+ timers.schedule(id, after: deadline - now()) { [weak self] in
19
+ guard let self, let entry = self.pending[id], entry.sequence == current else { return }
20
+ self.pending.removeValue(forKey: id)
21
+ self.onDue(id, entry.token)
22
+ }
23
+ }
24
+ func cancel(_ id: String) { pending.removeValue(forKey: id); timers.cancel(id) }
25
+ func cancelAll() { pending.removeAll(); timers.cancelAll() }
26
+ func isArmed(_ id: String) -> Bool { pending[id] != nil }
27
+ var hasArmed: Bool { !pending.isEmpty }
28
+ func takeDue(_ at: Double? = nil) -> [(String, Double)] {
29
+ let limit = at ?? now()
30
+ let due = pending.filter { $0.value.deadline <= limit }.sorted { $0.value.sequence < $1.value.sequence }
31
+ for (id, _) in due { pending.removeValue(forKey: id); timers.cancel(id) }
32
+ return due.map { ($0.key, $0.value.token) }
33
+ }
34
+ func fireDue() { timers.fireDue() }
35
+ }
@@ -0,0 +1,88 @@
1
+ import Foundation
2
+
3
+ enum MonitorState: Equatable { case unknown, satisfied, unsatisfied, unmonitored }
4
+
5
+ struct MonitorFailure: Equatable {
6
+ var id: String
7
+ var code: String
8
+ var message: String
9
+ }
10
+
11
+ final class GeofenceEventAdapter {
12
+ private var known: [String: MonitorState] = [:]
13
+ private var initialTrigger: [String: Bool] = [:]
14
+ private var reported: [String: Set<String>] = [:]
15
+ private var ready: Set<String> = []
16
+ private var expected: Set<String> = []
17
+ private var held: [String: [(MonitorState, [(String, String)])]] = [:]
18
+
19
+ func seed(_ id: String, _ state: MonitorState) {
20
+ if known[id] == nil { known[id] = state }
21
+ }
22
+
23
+ func expect(_ id: String) {
24
+ ready.remove(id); expected.insert(id)
25
+ }
26
+
27
+ func restored(_ id: String, initialTriggerEntry: Bool, state: MonitorState) -> ([OsGeofenceTransition], [MonitorFailure]) {
28
+ initialTrigger[id] = initialTriggerEntry
29
+ if known[id] == nil { known[id] = state }
30
+ expected.remove(id); ready.insert(id)
31
+ var transitions: [OsGeofenceTransition] = [], failures: [MonitorFailure] = []
32
+ for (state, diagnostics) in held.removeValue(forKey: id) ?? [] {
33
+ let result = process(id, state, diagnostics)
34
+ transitions += result.0; failures += result.1
35
+ }
36
+ return (transitions, failures)
37
+ }
38
+
39
+ func registered(_ id: String, initialTriggerEntry: Bool) {
40
+ known[id] = .unknown
41
+ initialTrigger[id] = initialTriggerEntry
42
+ reported[id] = nil; held[id] = nil
43
+ expected.remove(id); ready.insert(id)
44
+ }
45
+
46
+ func registered(_ id: String, initialTriggerEntry: Bool, current: MonitorState) -> ([OsGeofenceTransition], [MonitorFailure]) {
47
+ registered(id, initialTriggerEntry: initialTriggerEntry)
48
+ return process(id, current, [])
49
+ }
50
+
51
+ func removed(_ id: String) {
52
+ known[id] = nil; initialTrigger[id] = nil; reported[id] = nil; held[id] = nil
53
+ expected.remove(id); ready.remove(id)
54
+ }
55
+
56
+ func retain(_ ids: Set<String>) {
57
+ known = known.filter { ids.contains($0.key) }
58
+ initialTrigger = initialTrigger.filter { ids.contains($0.key) }
59
+ reported = reported.filter { ids.contains($0.key) }
60
+ held = held.filter { ids.contains($0.key) }
61
+ expected.formIntersection(ids); ready.formIntersection(ids)
62
+ }
63
+
64
+ func handle(_ id: String, _ state: MonitorState, failures diagnostics: [(String, String)]) -> ([OsGeofenceTransition], [MonitorFailure]) {
65
+ if !ready.contains(id), expected.contains(id) || known[id] != nil {
66
+ held[id, default: []].append((state, diagnostics))
67
+ return ([], [])
68
+ }
69
+ return process(id, state, diagnostics)
70
+ }
71
+
72
+ private func process(_ id: String, _ state: MonitorState, _ diagnostics: [(String, String)]) -> ([OsGeofenceTransition], [MonitorFailure]) {
73
+ var failures: [MonitorFailure] = []
74
+ for (code, message) in diagnostics where !(reported[id]?.contains(code) ?? false) {
75
+ reported[id, default: []].insert(code)
76
+ failures.append(MonitorFailure(id: id, code: code, message: "geofence '\(id)': " + message))
77
+ }
78
+ guard diagnostics.isEmpty, state == .satisfied || state == .unsatisfied, let previous = known[id] else { return ([], failures) }
79
+ reported[id] = nil
80
+ guard previous != state else { return ([], failures) }
81
+ known[id] = state
82
+ if previous == .unknown || previous == .unmonitored {
83
+ if state == .satisfied { return (initialTrigger[id] ?? true ? [OsGeofenceTransition(id: id, action: .enter, location: nil)] : [], failures) }
84
+ return ([OsGeofenceTransition(id: id, action: .exit, location: nil, initial: true)], failures)
85
+ }
86
+ return ([OsGeofenceTransition(id: id, action: state == .satisfied ? .enter : .exit, location: nil)], failures)
87
+ }
88
+ }