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