therms-device-tracker 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/ARCHITECTURE.md +145 -0
  2. package/CHANGELOG.md +26 -0
  3. package/LICENSE +21 -0
  4. package/README.md +386 -0
  5. package/android/build.gradle +25 -0
  6. package/android/src/main/AndroidManifest.xml +23 -0
  7. package/android/src/main/java/expo/modules/thermsdevicetracker/ActivityRecognitionProvider.kt +109 -0
  8. package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceProvider.kt +184 -0
  9. package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceTransitionReceiver.kt +34 -0
  10. package/android/src/main/java/expo/modules/thermsdevicetracker/LocationProvider.kt +84 -0
  11. package/android/src/main/java/expo/modules/thermsdevicetracker/LocationStore.kt +150 -0
  12. package/android/src/main/java/expo/modules/thermsdevicetracker/ScheduleProvider.kt +55 -0
  13. package/android/src/main/java/expo/modules/thermsdevicetracker/SyncProvider.kt +292 -0
  14. package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModule.kt +726 -0
  15. package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModuleSharedObject.kt +23 -0
  16. package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsLocationService.kt +129 -0
  17. package/app.plugin.js +1 -0
  18. package/build/DeviceSettings.d.ts +14 -0
  19. package/build/DeviceSettings.d.ts.map +1 -0
  20. package/build/DeviceSettings.js +24 -0
  21. package/build/DeviceSettings.js.map +1 -0
  22. package/build/Logger.d.ts +13 -0
  23. package/build/Logger.d.ts.map +1 -0
  24. package/build/Logger.js +27 -0
  25. package/build/Logger.js.map +1 -0
  26. package/build/NativeModule.d.ts +51 -0
  27. package/build/NativeModule.d.ts.map +1 -0
  28. package/build/NativeModule.js +159 -0
  29. package/build/NativeModule.js.map +1 -0
  30. package/build/ThermsDeviceTracker.types.d.ts +204 -0
  31. package/build/ThermsDeviceTracker.types.d.ts.map +1 -0
  32. package/build/ThermsDeviceTracker.types.js +34 -0
  33. package/build/ThermsDeviceTracker.types.js.map +1 -0
  34. package/build/ThermsDeviceTrackerModule.d.ts +43 -0
  35. package/build/ThermsDeviceTrackerModule.d.ts.map +1 -0
  36. package/build/ThermsDeviceTrackerModule.js +3 -0
  37. package/build/ThermsDeviceTrackerModule.js.map +1 -0
  38. package/build/ThermsDeviceTrackerModule.web.d.ts +47 -0
  39. package/build/ThermsDeviceTrackerModule.web.d.ts.map +1 -0
  40. package/build/ThermsDeviceTrackerModule.web.js +132 -0
  41. package/build/ThermsDeviceTrackerModule.web.js.map +1 -0
  42. package/build/ThermsDeviceTrackerModuleSharedObject.d.ts +46 -0
  43. package/build/ThermsDeviceTrackerModuleSharedObject.d.ts.map +1 -0
  44. package/build/ThermsDeviceTrackerModuleSharedObject.js +24 -0
  45. package/build/ThermsDeviceTrackerModuleSharedObject.js.map +1 -0
  46. package/build/index.d.ts +101 -0
  47. package/build/index.d.ts.map +1 -0
  48. package/build/index.js +221 -0
  49. package/build/index.js.map +1 -0
  50. package/build/plugin/index.d.ts +14 -0
  51. package/build/plugin/index.d.ts.map +1 -0
  52. package/build/plugin/index.js +83 -0
  53. package/build/plugin/index.js.map +1 -0
  54. package/build/tsconfig.tsbuildinfo +1 -0
  55. package/expo-module.config.json +9 -0
  56. package/ios/GeofenceManager.swift +221 -0
  57. package/ios/LocationProvider.swift +32 -0
  58. package/ios/LocationStore.swift +98 -0
  59. package/ios/MotionActivityProvider.swift +109 -0
  60. package/ios/ProviderMonitor.swift +33 -0
  61. package/ios/ScheduleManager.swift +33 -0
  62. package/ios/SyncManager.swift +186 -0
  63. package/ios/ThermsDeviceTracker.podspec +24 -0
  64. package/ios/ThermsDeviceTrackerModule.swift +632 -0
  65. package/ios/ThermsDeviceTrackerModuleSharedObject.swift +17 -0
  66. package/ios/ThermsGeofenceTests.swift +474 -0
  67. package/package.json +95 -0
@@ -0,0 +1,221 @@
1
+ import CoreLocation
2
+
3
+ /// Protocol for testability of region monitoring (avoids direct hard dep on CLLocationManager).
4
+ /// Allows injecting mocks in unit tests for GeofenceManager (see ThermsGeofenceTests.swift skeleton).
5
+ protocol GeofenceRegionMonitoring: AnyObject {
6
+ func startMonitoring(for region: CLRegion)
7
+ func stopMonitoring(for region: CLRegion)
8
+ }
9
+
10
+ // Extend for seamless use with real CLLocationManager (follows existing patterns).
11
+ extension CLLocationManager: GeofenceRegionMonitoring {}
12
+
13
+ /// Focused manager for geofencing concerns (CLCircularRegion + monitoring).
14
+ /// Follows the splitting direction started with LocationProvider.swift and ProviderMonitor.swift.
15
+ ///
16
+ /// Responsibilities:
17
+ /// - Add / remove / list geofences
18
+ /// - Start/stop monitoring
19
+ /// - Build geofence event payloads
20
+ /// - Notify via simple callbacks (keeps the manager decoupled from sendEvent)
21
+ ///
22
+ /// The main ThermsDeviceTrackerModule remains the CLLocationManagerDelegate and
23
+ /// forwards region events here via the handle* methods.
24
+ ///
25
+ /// Config notes: geofence section (enabled, proximityRadius) may be respected by caller (module)
26
+ /// on ready/start; manager itself is config-agnostic for minimal surface. Persistence of regions
27
+ /// via LocationStore (save/loadGeofences) for durability across restarts.
28
+ final class GeofenceManager {
29
+ // Callbacks supplied by owner (the module)
30
+ var onGeofence: (([String: Any]) -> Void)?
31
+ var onGeofencesChange: (([String: Any]) -> Void)?
32
+
33
+ /// Direct provider for last location attachment (more direct than post-event mutation in owner).
34
+ /// Owner injects e.g. { [weak self] in self?.getLastLocationDict() }
35
+ var lastLocationProvider: (() -> [String: Any]?)?
36
+
37
+ private var monitoredRegions: [String: CLCircularRegion] = [:]
38
+ private var geofenceExtras: [String: [String: Any]] = [:]
39
+
40
+ // Uses protocol for testability (direct monitoring seam). Real CLLocationManager via extension.
41
+ private weak var regionMonitor: GeofenceRegionMonitoring?
42
+
43
+ // Store for geofence persistence (durability). Strong ref (module owns the store instance);
44
+ // weak would risk nil despite owner lifetime.
45
+ private var store: LocationStore?
46
+
47
+ init(monitor: GeofenceRegionMonitoring, store: LocationStore? = nil) {
48
+ self.regionMonitor = monitor
49
+ self.store = store
50
+ // Stub: could auto-restore here, but caller controls (e.g. on startGeofences)
51
+ }
52
+
53
+ // MARK: - Public API used by module
54
+
55
+ func addGeofence(from dict: [String: Any]) {
56
+ guard
57
+ let identifier = dict["identifier"] as? String,
58
+ let mon = regionMonitor,
59
+ let region = makeRegion(from: dict)
60
+ else { return } // silent no-op on bad input (consistent with prior behavior)
61
+
62
+ monitoredRegions[identifier] = region
63
+ if let extras = dict["extras"] as? [String: Any] {
64
+ geofenceExtras[identifier] = extras
65
+ }
66
+
67
+ if canMonitorRegions() {
68
+ mon.startMonitoring(for: region)
69
+ }
70
+
71
+ persistGeofences()
72
+ notifyGeofencesChange()
73
+ }
74
+
75
+ private func canMonitorRegions() -> Bool {
76
+ return CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self)
77
+ }
78
+
79
+ private func makeRegion(from dict: [String: Any]) -> CLCircularRegion? {
80
+ guard
81
+ let lat = dict["latitude"] as? Double,
82
+ let lon = dict["longitude"] as? Double,
83
+ let radius = dict["radius"] as? Double,
84
+ let identifier = dict["identifier"] as? String
85
+ else { return nil }
86
+ let region = CLCircularRegion(
87
+ center: CLLocationCoordinate2D(latitude: lat, longitude: lon),
88
+ radius: radius,
89
+ identifier: identifier
90
+ )
91
+ region.notifyOnEntry = (dict["notifyOnEntry"] as? Bool) ?? true
92
+ region.notifyOnExit = (dict["notifyOnExit"] as? Bool) ?? true
93
+ return region
94
+ }
95
+
96
+ func addGeofences(_ geofences: [[String: Any]]) {
97
+ for g in geofences {
98
+ addGeofence(from: g)
99
+ }
100
+ }
101
+
102
+ func removeGeofence(identifier: String) {
103
+ if let region = monitoredRegions.removeValue(forKey: identifier), let mon = regionMonitor {
104
+ mon.stopMonitoring(for: region)
105
+ }
106
+ geofenceExtras.removeValue(forKey: identifier)
107
+ persistGeofences()
108
+ notifyGeofencesChange()
109
+ }
110
+
111
+ func removeGeofences(identifiers: [String]?) {
112
+ if let ids = identifiers {
113
+ for id in ids {
114
+ removeGeofence(identifier: id)
115
+ }
116
+ } else {
117
+ stopAll()
118
+ }
119
+ }
120
+
121
+ func getGeofences() -> [[String: Any]] {
122
+ return monitoredRegions.map { (id, region) in
123
+ var d: [String: Any] = [
124
+ "identifier": id,
125
+ "latitude": region.center.latitude,
126
+ "longitude": region.center.longitude,
127
+ "radius": region.radius,
128
+ "notifyOnEntry": region.notifyOnEntry,
129
+ "notifyOnExit": region.notifyOnExit,
130
+ ]
131
+ if let ex = geofenceExtras[id] { d["extras"] = ex }
132
+ return d
133
+ }
134
+ }
135
+
136
+ func startMonitoring() {
137
+ guard let mon = regionMonitor else { return }
138
+ if !canMonitorRegions() { return }
139
+ for (_, region) in monitoredRegions {
140
+ mon.startMonitoring(for: region)
141
+ }
142
+ }
143
+
144
+ // MARK: - Delegate forwarding (called from main module)
145
+
146
+ func handleDidEnterRegion(_ region: CLRegion) {
147
+ guard let circular = region as? CLCircularRegion else { return }
148
+ let event = buildEvent(identifier: circular.identifier, action: "ENTER")
149
+ onGeofence?(event)
150
+ }
151
+
152
+ func handleDidExitRegion(_ region: CLRegion) {
153
+ guard let circular = region as? CLCircularRegion else { return }
154
+ let event = buildEvent(identifier: circular.identifier, action: "EXIT")
155
+ onGeofence?(event)
156
+ }
157
+
158
+ func handleDidDetermineState(_ state: CLRegionState, for region: CLRegion) {
159
+ // For now we only use enter/exit. Consumers can request state manually if needed.
160
+ // Future: could emit an initial "INSIDE" synthetic event.
161
+ _ = state
162
+ _ = region
163
+ }
164
+
165
+ // MARK: - Internal
166
+
167
+ private func buildEvent(identifier: String, action: String) -> [String: Any] {
168
+ var event: [String: Any] = [
169
+ "identifier": identifier,
170
+ "action": action,
171
+ ]
172
+ // Direct attachment via injected provider (fixes indirect pattern).
173
+ if let loc = lastLocationProvider?() {
174
+ event["location"] = loc
175
+ }
176
+ if let ex = geofenceExtras[identifier] {
177
+ event["extras"] = ex
178
+ }
179
+ return event
180
+ }
181
+
182
+ private func notifyGeofencesChange() {
183
+ let payload: [String: Any] = [
184
+ "on": getGeofences(),
185
+ "off": []
186
+ ]
187
+ onGeofencesChange?(payload)
188
+ }
189
+
190
+ private func stopAll() {
191
+ guard let mon = regionMonitor else { return }
192
+ for (_, region) in monitoredRegions {
193
+ mon.stopMonitoring(for: region)
194
+ }
195
+ monitoredRegions.removeAll()
196
+ geofenceExtras.removeAll()
197
+ persistGeofences()
198
+ notifyGeofencesChange()
199
+ }
200
+
201
+ // MARK: - Persistence (geofence durability via store)
202
+
203
+ private func persistGeofences() {
204
+ guard let s = store else { return }
205
+ s.saveGeofences(getGeofences())
206
+ }
207
+
208
+ /// Restore previously persisted geofences (call before startMonitoring for durability).
209
+ /// Populates memory only (avoids side-effect start); startMonitoring will activate.
210
+ func restorePersistedGeofences() {
211
+ guard let s = store else { return }
212
+ let saved = s.loadGeofences()
213
+ for g in saved {
214
+ guard let identifier = g["identifier"] as? String,
215
+ let region = makeRegion(from: g) else { continue }
216
+ monitoredRegions[identifier] = region
217
+ if let ex = g["extras"] as? [String: Any] { geofenceExtras[identifier] = ex }
218
+ }
219
+ // do not notify or start here; caller does
220
+ }
221
+ }
@@ -0,0 +1,32 @@
1
+ import CoreLocation
2
+
3
+ /// Extracted location management.
4
+ /// Main ThermsDeviceTrackerModule (thin coordinator) delegates config, start/stop of
5
+ /// location updates to this provider. Delegate ownership for didUpdate* remains on the module
6
+ /// (parallel to how GeofenceManager receives forwarded region events).
7
+ /// The provider's manager is shared with GeofenceManager via the region monitoring protocol.
8
+ class LocationProvider {
9
+ let manager = CLLocationManager()
10
+
11
+ func configure(desiredAccuracy: CLLocationAccuracy, distanceFilter: CLLocationDistance) {
12
+ manager.desiredAccuracy = desiredAccuracy
13
+ manager.distanceFilter = distanceFilter
14
+ }
15
+
16
+ /// Apply background and pause settings prior to starting updates (from tracking options).
17
+ func applyBackgroundAndPauseSettings(allowsBackground: Bool, pausesAutomatically: Bool) {
18
+ manager.allowsBackgroundLocationUpdates = allowsBackground
19
+ manager.pausesLocationUpdatesAutomatically = pausesAutomatically
20
+ }
21
+
22
+ /// Starts location updates. Caller is responsible for checking locationServicesEnabled()
23
+ /// (to preserve error emission in module).
24
+ func startUpdatingLocation() {
25
+ manager.startUpdatingLocation()
26
+ }
27
+
28
+ func stopUpdating() {
29
+ manager.stopUpdatingLocation()
30
+ manager.allowsBackgroundLocationUpdates = false
31
+ }
32
+ }
@@ -0,0 +1,98 @@
1
+ import Foundation
2
+
3
+ /// Dedicated persistence for location history (and potentially geofences in future).
4
+ /// Extracted to keep the main module thin and make the storage logic testable.
5
+ ///
6
+ /// Uses UserDefaults for simplicity (no extra deps). In a production version
7
+ /// a proper database (Core Data / SQLite via GRDB etc.) could replace this.
8
+ ///
9
+ /// Follows testability pattern: accepts UserDefaults (default .standard) for injection in tests.
10
+ /// See LocationProvider.swift style for focused extract.
11
+ final class LocationStore {
12
+ private let defaults: UserDefaults
13
+ private let persistedLocationsKey = "ThermsPersistedLocations"
14
+ private let persistedGeofencesKey = "ThermsPersistedGeofences"
15
+ private let maxEntries = 500
16
+
17
+ init(defaults: UserDefaults = .standard) {
18
+ self.defaults = defaults
19
+ }
20
+
21
+ /// Returns persisted locations. Each record should have at least:
22
+ /// latitude, longitude, timestamp, and ideally an "id".
23
+ func load() -> [[String: Any]] {
24
+ guard let arr = defaults.array(forKey: persistedLocationsKey) as? [[String: Any]] else {
25
+ return []
26
+ }
27
+ return arr
28
+ }
29
+
30
+ private func save(_ list: [[String: Any]]) {
31
+ // Keep bounded
32
+ let pruned = list.suffix(maxEntries)
33
+ defaults.set(Array(pruned), forKey: persistedLocationsKey)
34
+ }
35
+
36
+ func clear() {
37
+ defaults.removeObject(forKey: persistedLocationsKey)
38
+ }
39
+
40
+ /// Insert a location. Ensures an "id" (UUID string) exists for reliable destroy.
41
+ func insert(_ location: [String: Any]) {
42
+ var record = location
43
+ if record["id"] == nil {
44
+ record["id"] = UUID().uuidString
45
+ }
46
+ var current = load()
47
+ current.append(record)
48
+ save(current)
49
+ }
50
+
51
+ /// Remove by stable id. Improved removal: requires id (no legacy pop/clear fallback inside).
52
+ /// Callers (module) branch to clear() when no id for destroyLocation.
53
+ /// Legacy records w/o id tolerated (ignored on filter).
54
+ func remove(byId id: String?) {
55
+ guard let targetId = id else { return }
56
+
57
+ var current = load()
58
+ current.removeAll { rec in
59
+ if let existing = rec["id"] as? String {
60
+ return existing == targetId
61
+ }
62
+ return false
63
+ }
64
+ save(current)
65
+ }
66
+
67
+ func count() -> Int {
68
+ return load().count
69
+ }
70
+
71
+ /// Convenience to get all persisted (used by getLocations merge).
72
+ func getAll() -> [[String: Any]] {
73
+ return load()
74
+ }
75
+
76
+ // MARK: - Geofence persistence (for durability / restore across restarts)
77
+ // Lightweight array-of-dicts (geofences unbounded for now; expected small count; see locations for maxEntries).
78
+ // Used by GeofenceManager. Follows same UserDefaults pattern.
79
+
80
+ func loadGeofences() -> [[String: Any]] {
81
+ guard let arr = defaults.array(forKey: persistedGeofencesKey) as? [[String: Any]] else {
82
+ return []
83
+ }
84
+ return arr
85
+ }
86
+
87
+ func saveGeofences(_ geofences: [[String: Any]]) {
88
+ defaults.set(geofences, forKey: persistedGeofencesKey)
89
+ }
90
+
91
+ func clearGeofences() {
92
+ defaults.removeObject(forKey: persistedGeofencesKey)
93
+ }
94
+ }
95
+
96
+ // Conform to SyncDataSource protocol (simple seam for SyncManager testability/injection; no behavior change).
97
+ // Sync always reads via getAll() at execution (live for batch/immediate).
98
+ extension LocationStore: SyncDataSource {}
@@ -0,0 +1,109 @@
1
+ import CoreMotion
2
+
3
+ /// Focused provider for motion activity + pedometer (mirrors GeofenceManager / ScheduleManager seams).
4
+ ///
5
+ /// Extracted to keep the main module thin (following LocationProvider + ProviderMonitor direction).
6
+ /// Responsibilities:
7
+ /// - Start/stop CMMotionActivityManager + CMPedometer updates.
8
+ /// - Map native records to dicts.
9
+ /// - Emit via callbacks (`onActivityUpdate`, `onPedometerUpdate`) — module wires `sendEvent`.
10
+ ///
11
+ /// Injection / test seam precedent: callbacks + error closure (no direct sendEvent).
12
+ /// Session state (append) remains in the coordinator.
13
+ final class MotionActivityProvider {
14
+ var onActivityUpdate: (([String: Any]) -> Void)?
15
+ var onPedometerUpdate: (([String: Any]) -> Void)?
16
+ var onError: ((String, String) -> Void)?
17
+
18
+ private var activityManager = CMMotionActivityManager()
19
+ private var pedometer = CMPedometer()
20
+
21
+ private var enableActivity = true
22
+ private var enablePedometer = true
23
+
24
+ func configure(enableActivity: Bool, enablePedometer: Bool) {
25
+ self.enableActivity = enableActivity
26
+ self.enablePedometer = enablePedometer
27
+ }
28
+
29
+ func start() {
30
+ if enableActivity && CMMotionActivityManager.isActivityAvailable() {
31
+ activityManager.startActivityUpdates(to: .main) { [weak self] activity in
32
+ guard let self = self, let activity = activity else { return }
33
+ let record = self.mapActivity(activity)
34
+ self.onActivityUpdate?(self.activityRecordToDict(record))
35
+ }
36
+ }
37
+
38
+ if enablePedometer && CMPedometer.isStepCountingAvailable() {
39
+ pedometer.startUpdates(from: Date()) { [weak self] data, error in
40
+ guard let self = self, let data = data else { return }
41
+ if let error = error {
42
+ self.onError?("pedometer_error", error.localizedDescription)
43
+ return
44
+ }
45
+ let record = self.makePedometerRecord(data)
46
+ self.onPedometerUpdate?(self.pedometerRecordToDict(record))
47
+ }
48
+ }
49
+ }
50
+
51
+ func stop() {
52
+ if CMMotionActivityManager.isActivityAvailable() {
53
+ activityManager.stopActivityUpdates()
54
+ }
55
+ if CMPedometer.isStepCountingAvailable() {
56
+ pedometer.stopUpdates()
57
+ }
58
+ }
59
+
60
+ // Internal mapping (moved from module)
61
+ private struct ActivityRecord {
62
+ let type: String
63
+ let confidence: Double
64
+ let timestamp: Double
65
+ }
66
+
67
+ private struct PedometerRecord {
68
+ let steps: Int
69
+ let distance: Double?
70
+ let floorsAscended: Int?
71
+ let floorsDescended: Int?
72
+ let timestamp: Double
73
+ }
74
+
75
+ private func mapActivity(_ activity: CMMotionActivity) -> ActivityRecord {
76
+ let type: String
77
+ if activity.stationary { type = "still" }
78
+ else if activity.walking { type = "walking" }
79
+ else if activity.running { type = "running" }
80
+ else if activity.cycling { type = "cycling" }
81
+ else if activity.automotive { type = "automotive" }
82
+ else { type = "unknown" }
83
+
84
+ let conf = Double(activity.confidence.rawValue) / 2.0
85
+ return ActivityRecord(type: type, confidence: conf, timestamp: Date().timeIntervalSince1970 * 1000)
86
+ }
87
+
88
+ private func makePedometerRecord(_ data: CMPedometerData) -> PedometerRecord {
89
+ return PedometerRecord(
90
+ steps: data.numberOfSteps.intValue,
91
+ distance: data.distance?.doubleValue,
92
+ floorsAscended: data.floorsAscended?.intValue,
93
+ floorsDescended: data.floorsDescended?.intValue,
94
+ timestamp: Date().timeIntervalSince1970 * 1000
95
+ )
96
+ }
97
+
98
+ private func activityRecordToDict(_ r: ActivityRecord) -> [String: Any] {
99
+ return ["type": r.type, "confidence": r.confidence, "timestamp": r.timestamp]
100
+ }
101
+
102
+ private func pedometerRecordToDict(_ r: PedometerRecord) -> [String: Any] {
103
+ var d: [String: Any] = ["steps": r.steps, "timestamp": r.timestamp]
104
+ if let v = r.distance { d["distance"] = v }
105
+ if let v = r.floorsAscended { d["floorsAscended"] = v }
106
+ if let v = r.floorsDescended { d["floorsDescended"] = v }
107
+ return d
108
+ }
109
+ }
@@ -0,0 +1,33 @@
1
+ import CoreLocation
2
+
3
+ /// Dedicated provider / authorization monitoring.
4
+ /// The thin coordinator (ThermsDeviceTrackerModule) delegates provider state to this
5
+ /// for getProviderState() and onProviderChange. Pass the active manager (preferred)
6
+ /// for accuracyAuthorization (iOS14+); falls back to temp instance if omitted.
7
+ struct ProviderMonitor {
8
+ static func currentState(using manager: CLLocationManager? = nil) -> [String: Any] {
9
+ // Use instance-based API for authorizationStatus (CLLocationManager.authorizationStatus() class method is deprecated since iOS 14).
10
+ // locationServicesEnabled() remains a class method as it is a system-wide setting.
11
+ let lm = manager ?? CLLocationManager()
12
+ let servicesEnabled = CLLocationManager.locationServicesEnabled()
13
+ let auth = lm.authorizationStatus
14
+ let status: String
15
+ switch auth {
16
+ case .notDetermined: status = "not_determined"
17
+ case .restricted: status = "restricted"
18
+ case .denied: status = "denied"
19
+ case .authorizedAlways: status = "always"
20
+ case .authorizedWhenInUse: status = "when_in_use"
21
+ @unknown default: status = "not_determined"
22
+ }
23
+
24
+ var dict: [String: Any] = [
25
+ "enabled": servicesEnabled,
26
+ "status": status,
27
+ ]
28
+ // iOS 14+ accuracyAuthorization always available (min iOS 16.4)
29
+ let acc = lm.accuracyAuthorization
30
+ dict["accuracyAuthorization"] = (acc == .fullAccuracy) ? "full" : "reduced"
31
+ return dict
32
+ }
33
+ }
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+
3
+ /// Very lightweight scheduler manager.
4
+ /// In a more complete implementation this would parse schedule windows from config
5
+ /// and coordinate with background modes / BGTaskScheduler.
6
+ ///
7
+ /// Accepts config for testability/configurability. Better notes on background limitations.
8
+ final class ScheduleManager {
9
+ var onSchedule: (([String: Any]) -> Void)?
10
+
11
+ private var timer: Timer?
12
+
13
+ /// Start with optional config (e.g. from ThermsConfig.schedule). Configurable heartbeat interval.
14
+ /// NOTE: This uses NSTimer (foreground only for demo). For true background schedules on iOS,
15
+ /// use BGTaskScheduler + BGAppRefresh / Background Modes (see background config + enable).
16
+ /// Config keys honored: "interval" (seconds, default 60).
17
+ func start(config: [String: Any]? = nil) {
18
+ stop()
19
+ onSchedule?(["state": "started"])
20
+
21
+ let interval = (config?["interval"] as? TimeInterval) ?? 60.0
22
+ // Demo heartbeat. Real schedules evaluate time-of-day rules and call start/stopTracking.
23
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
24
+ self?.onSchedule?(["state": "heartbeat"])
25
+ }
26
+ }
27
+
28
+ func stop() {
29
+ timer?.invalidate()
30
+ timer = nil
31
+ onSchedule?(["state": "stopped"])
32
+ }
33
+ }