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