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,632 @@
1
+ import ExpoModulesCore
2
+ import CoreLocation
3
+ import CoreMotion
4
+
5
+ public class ThermsDeviceTrackerModule: Module {
6
+ // Thin coordinator following the architecture: delegates to focused providers.
7
+ // Location config/start/stop delegated to LocationProvider.
8
+ // Provider/authorization state delegated to ProviderMonitor.
9
+ // (See LocationProvider.swift, ProviderMonitor.swift, GeofenceManager.swift, etc.)
10
+ // Delegate for CLLocationManager remains here (to forward location + region events);
11
+ // manager instance is owned by locationProvider and shared for geofences.
12
+ private lazy var locationProvider = LocationProvider()
13
+
14
+ private var isTracking = false
15
+ private var enableActivity = true
16
+ private var enablePedometer = true
17
+ private var sessionLocations: [LocationPointRecord] = []
18
+ private var sessionActivities: [ActivityRecord] = []
19
+ private var sessionPedometer: [PedometerRecord] = []
20
+ private var currentActivity: ActivityRecord?
21
+ private var lastLocation: LocationPointRecord?
22
+ private var sessionStartedAt: Date?
23
+ private var sessionId: String?
24
+
25
+ // Remember last sync config for explicit startSync() after ready/start.
26
+ private var syncConfigFromLastStart: [String: Any]?
27
+
28
+ // For proper delegate-driven async permission requests (replaces the withCheckedContinuation + asyncAfter(0.6) hack).
29
+ // Resumed in handleAuthorizationChange when locationManagerDidChangeAuthorization fires after user responds to prompt.
30
+ private var pendingPermissionContinuation: CheckedContinuation<[String: Any], Never>?
31
+
32
+ // Focused managers (extraction to keep this file as thin coordinator)
33
+ private lazy var locationStore = LocationStore() // default .standard demonstrates seam (tests can override)
34
+ private lazy var geofenceManager: GeofenceManager = {
35
+ let mgr = GeofenceManager(monitor: locationProvider.manager, store: locationStore)
36
+ // Direct last location provider (attachment now happens inside manager's buildEvent)
37
+ mgr.lastLocationProvider = { [weak self] in
38
+ guard let self = self, let loc = self.lastLocation else { return nil }
39
+ return self.locationRecordToDict(loc)
40
+ }
41
+ mgr.onGeofence = { [weak self] event in
42
+ self?.sendEvent("onGeofence", event)
43
+ }
44
+ mgr.onGeofencesChange = { [weak self] payload in
45
+ self?.sendEvent("onGeofencesChange", payload)
46
+ }
47
+ return mgr
48
+ }()
49
+ private lazy var scheduleManager: ScheduleManager = {
50
+ let sm = ScheduleManager()
51
+ sm.onSchedule = { [weak self] s in self?.sendEvent("onSchedule", s) }
52
+ return sm
53
+ }()
54
+
55
+ private lazy var motionActivityProvider = MotionActivityProvider()
56
+
57
+ // Opt-in bg sync seam (native HTTP only; follows GeofenceManager/ScheduleManager pattern exactly).
58
+ // Uses onSync callback (not direct sendEvent inside manager). Injected store for SyncDataSource test seam.
59
+ // Naming: SyncManager (iOS); cross-platform note: Android equivalent is SyncProvider (see its docs).
60
+ private lazy var syncManager: SyncManager = {
61
+ let mgr = SyncManager(store: locationStore)
62
+ mgr.onSync = { [weak self] evt in self?.sendEvent("onSync", evt) }
63
+ return mgr
64
+ }()
65
+
66
+ // Records (internal, will map to JS structs)
67
+ private struct LocationPointRecord {
68
+ let latitude: Double
69
+ let longitude: Double
70
+ let altitude: Double?
71
+ let accuracy: Double?
72
+ let speed: Double?
73
+ let heading: Double?
74
+ let timestamp: Double
75
+ }
76
+
77
+ private struct ActivityRecord {
78
+ let type: String
79
+ let confidence: Double
80
+ let timestamp: Double
81
+ }
82
+
83
+ private struct PedometerRecord {
84
+ let steps: Int
85
+ let distance: Double?
86
+ let floorsAscended: Int?
87
+ let floorsDescended: Int?
88
+ let timestamp: Double
89
+ }
90
+
91
+ public func definition() -> ModuleDefinition {
92
+ Name("ThermsDeviceTracker")
93
+
94
+ Events(
95
+ "onLocationUpdate",
96
+ "onMotionChange",
97
+ "onActivityUpdate",
98
+ "onPedometerUpdate",
99
+ "onTrackingStateChange",
100
+ "onProviderChange",
101
+ "onGeofence",
102
+ "onGeofencesChange",
103
+ "onSchedule",
104
+ "onSync",
105
+ "onError"
106
+ )
107
+
108
+ OnCreate {
109
+ // Delegate assignment uses cast to avoid direct conformance issues with Expo's Module/BaseModule + NSObjectProtocol.
110
+ // Delegate methods are @objc for runtime dispatch.
111
+ let lm = locationProvider.manager
112
+ lm.delegate = (self as AnyObject) as? CLLocationManagerDelegate
113
+ // Initial defaults now via provider (delegated).
114
+ locationProvider.configure(desiredAccuracy: kCLLocationAccuracyBest, distanceFilter: kCLDistanceFilterNone)
115
+ }
116
+
117
+ OnDestroy {
118
+ self.stopTracking()
119
+ self.syncManager.stop()
120
+ self.scheduleManager.stop()
121
+ }
122
+
123
+ // Permissions
124
+ AsyncFunction("getPermissionsAsync") { () -> [String: Any] in
125
+ return self.getPermissionStatuses()
126
+ }
127
+
128
+ AsyncFunction("requestPermissionsAsync") { (options: [String: Any]?) -> [String: Any] in
129
+ let requestBackground = (options?["background"] as? Bool) ?? false
130
+ return try await self.requestPermissions(requestBackground: requestBackground)
131
+ }
132
+
133
+ // Control
134
+ AsyncFunction("startTrackingAsync") { (options: [String: Any]?) in
135
+ self.startTracking(options: options)
136
+ }.runOnQueue(.main)
137
+
138
+ AsyncFunction("stopTrackingAsync") {
139
+ self.stopTracking()
140
+ }.runOnQueue(.main)
141
+
142
+ // Getters
143
+ AsyncFunction("getCurrentLocationAsync") { () -> [String: Any]? in
144
+ guard let loc = lastLocation else { return nil }
145
+ return self.locationRecordToDict(loc)
146
+ }
147
+
148
+ Function("getLastKnownLocation") { () -> [String: Any]? in
149
+ guard let loc = lastLocation else { return nil }
150
+ return self.locationRecordToDict(loc)
151
+ }
152
+
153
+ Function("getCurrentActivity") { () -> [String: Any]? in
154
+ guard let act = currentActivity else { return nil }
155
+ return self.activityRecordToDict(act)
156
+ }
157
+
158
+ Function("getTrackingStatus") { () -> [String: Any] in
159
+ return self.buildTrackingStatusDict()
160
+ }
161
+
162
+ Function("getProviderState") { () -> [String: Any] in
163
+ return self.buildProviderChangeDict()
164
+ }
165
+
166
+ // Geofencing (delegated to manager)
167
+ AsyncFunction("addGeofence") { (geofence: [String: Any]) in
168
+ self.geofenceManager.addGeofence(from: geofence)
169
+ }
170
+
171
+ AsyncFunction("addGeofences") { (geofences: [[String: Any]]) in
172
+ self.geofenceManager.addGeofences(geofences)
173
+ }
174
+
175
+ AsyncFunction("removeGeofence") { (identifier: String) in
176
+ self.geofenceManager.removeGeofence(identifier: identifier)
177
+ }
178
+
179
+ AsyncFunction("removeGeofences") { (identifiers: [String]?) in
180
+ self.geofenceManager.removeGeofences(identifiers: identifiers)
181
+ }
182
+
183
+ AsyncFunction("getGeofences") { () -> [[String: Any]] in
184
+ return self.geofenceManager.getGeofences()
185
+ }
186
+
187
+ AsyncFunction("startGeofences") { () in
188
+ // Improve durability: restore persisted regions first (if any), then monitor.
189
+ self.geofenceManager.restorePersistedGeofences()
190
+ self.geofenceManager.startMonitoring()
191
+ }
192
+
193
+ // Persistence (delegated to store)
194
+ AsyncFunction("getLocations") { () -> [[String: Any]] in
195
+ return self.locationStore.getAll() + self.sessionLocations.map { self.locationRecordToDict($0) }
196
+ }
197
+
198
+ AsyncFunction("getCount") { () -> Int in
199
+ return self.locationStore.count() + self.sessionLocations.count
200
+ }
201
+
202
+ AsyncFunction("destroyLocations") {
203
+ self.locationStore.clear()
204
+ self.sessionLocations.removeAll()
205
+ }
206
+
207
+ AsyncFunction("destroyLocation") { (uuid: String?) in
208
+ if let u = uuid {
209
+ self.locationStore.remove(byId: u)
210
+ } else {
211
+ self.locationStore.clear()
212
+ }
213
+ }
214
+
215
+ AsyncFunction("insertLocation") { (loc: [String: Any]) in
216
+ self.locationStore.insert(loc)
217
+ self.triggerImmediateSyncIfNeeded()
218
+ }
219
+
220
+ // Scheduler (delegated)
221
+ AsyncFunction("startSchedule") {
222
+ self.scheduleManager.start()
223
+ }
224
+
225
+ AsyncFunction("stopSchedule") {
226
+ self.scheduleManager.stop()
227
+ }
228
+
229
+ // Opt-in native bg sync (config primary from ready/start; explicit start/stopSync for control like schedule).
230
+ // Delegates fully to syncManager (which uses onSync callback + live store reads).
231
+ AsyncFunction("startSync") {
232
+ if let cfg = self.syncConfigFromLastStart {
233
+ self.syncManager.start(config: cfg)
234
+ } else {
235
+ self.syncManager.syncNow() // trigger immediate if already configured
236
+ }
237
+ }
238
+
239
+ AsyncFunction("stopSync") {
240
+ self.syncManager.stop()
241
+ }
242
+
243
+ AsyncFunction("getCurrentSessionHistory") { () -> [String: Any] in
244
+ return [
245
+ "locations": self.sessionLocations.map { self.locationRecordToDict($0) },
246
+ "activities": self.sessionActivities.map { self.activityRecordToDict($0) },
247
+ "pedometer": self.sessionPedometer.map { self.pedometerRecordToDict($0) },
248
+ ]
249
+ }
250
+
251
+ AsyncFunction("clearCurrentSessionHistory") {
252
+ self.sessionLocations.removeAll()
253
+ self.sessionActivities.removeAll()
254
+ self.sessionPedometer.removeAll()
255
+ }
256
+
257
+ // Shared Object (live state handle / facade to shared module state)
258
+ // Design note: ThermsTracker is intentionally a lightweight proxy handle.
259
+ // All instances reflect the single module-owned tracking session (no per-instance state).
260
+ // We wire a weak back-ref so properties "use" the ref parameter (better alignment with SharedObject pattern).
261
+ // See src/ThermsDeviceTrackerModuleSharedObject.ts for full rationale + public API surface.
262
+ Class(ThermsTracker.self) {
263
+ Constructor {
264
+ let tracker = ThermsTracker()
265
+ tracker.ownerModule = self
266
+ return tracker
267
+ }
268
+
269
+ Property("isTracking") { (ref: ThermsTracker) -> Bool in
270
+ return ref.ownerModule?.isTracking ?? self.isTracking
271
+ }
272
+
273
+ Property("trackingStatus") { (ref: ThermsTracker) -> [String: Any] in
274
+ return ref.ownerModule?.buildTrackingStatusDict() ?? self.buildTrackingStatusDict()
275
+ }
276
+
277
+ Property("lastLocation") { (ref: ThermsTracker) -> [String: Any]? in
278
+ let module = ref.ownerModule ?? self
279
+ guard let loc = module.lastLocation else { return nil }
280
+ return module.locationRecordToDict(loc)
281
+ }
282
+
283
+ Property("currentActivity") { (ref: ThermsTracker) -> [String: Any]? in
284
+ let module = ref.ownerModule ?? self
285
+ guard let act = module.currentActivity else { return nil }
286
+ return module.activityRecordToDict(act)
287
+ }
288
+
289
+ Property("sessionStats") { (ref: ThermsTracker) -> [String: Any]? in
290
+ let module = ref.ownerModule ?? self
291
+ guard let started = module.sessionStartedAt else { return nil }
292
+ return [
293
+ "startTime": started.timeIntervalSince1970 * 1000,
294
+ "pointCount": module.sessionLocations.count,
295
+ "totalSteps": module.sessionPedometer.last?.steps ?? 0,
296
+ ]
297
+ }
298
+ }
299
+ }
300
+
301
+ // MARK: - Permissions
302
+
303
+ private func getPermissionStatuses() -> [String: Any] {
304
+ var result: [String: Any] = [:]
305
+
306
+ // Use the provider's manager instance (class method CLLocationManager.authorizationStatus() is deprecated).
307
+ let locStatus = locationProvider.manager.authorizationStatus
308
+ result["location"] = permissionStatusString(from: locStatus)
309
+
310
+ // Background location is granted *only* for .authorizedAlways.
311
+ // WhenInUse means bg not granted (even though loc is). This was previously mirroring incorrectly.
312
+ // "undetermined" only for initial notDetermined (supports re-prompts for background).
313
+ let bgStatus: String
314
+ if locStatus == .authorizedAlways {
315
+ bgStatus = "granted"
316
+ } else if locStatus == .denied || locStatus == .restricted {
317
+ bgStatus = "denied"
318
+ } else if locStatus == .authorizedWhenInUse {
319
+ bgStatus = "denied"
320
+ } else {
321
+ bgStatus = "undetermined"
322
+ }
323
+ result["backgroundLocation"] = bgStatus
324
+ result["canAskAgain"] = locStatus == .notDetermined
325
+
326
+ if #available(iOS 11.0, *) {
327
+ let motionStatus = CMMotionActivityManager.authorizationStatus()
328
+ result["motion"] = motionStatus == .authorized ? "granted" : (motionStatus == .denied ? "denied" : "undetermined")
329
+ } else {
330
+ result["motion"] = "granted"
331
+ }
332
+
333
+ return result
334
+ }
335
+
336
+ private func permissionStatusString(from status: CLAuthorizationStatus) -> String {
337
+ switch status {
338
+ case .authorizedAlways, .authorizedWhenInUse: return "granted"
339
+ case .denied, .restricted: return "denied"
340
+ default: return "undetermined"
341
+ }
342
+ }
343
+
344
+ private func requestPermissions(requestBackground: Bool) async throws -> [String: Any] {
345
+ let lm = locationProvider.manager
346
+ let currentStatus = lm.authorizationStatus
347
+
348
+ // Only await the delegate callback (via continuation) if a prompt will be shown and didChangeAuthorization will fire.
349
+ // - notDetermined: system will show prompt; delegate fires on response.
350
+ // - whenInUse + background: system shows upgrade prompt for "Always"; delegate fires after.
351
+ // If already determined and no upgrade applicable, return immediately (no delegate callback will occur for this call).
352
+ // Motion has no explicit request API (CMMotionActivityManager); its status is read on return (updated after activity use).
353
+ let shouldAwaitDelegate = currentStatus == .notDetermined ||
354
+ (requestBackground && currentStatus == .authorizedWhenInUse)
355
+
356
+ if !shouldAwaitDelegate {
357
+ return getPermissionStatuses()
358
+ }
359
+
360
+ return await withCheckedContinuation { continuation in
361
+ pendingPermissionContinuation = continuation
362
+ if requestBackground {
363
+ lm.requestAlwaysAuthorization()
364
+ } else {
365
+ lm.requestWhenInUseAuthorization()
366
+ }
367
+ // IMPORTANT: removed asyncAfter(0.6) hack. Completion driven by delegate.
368
+ // See handleAuthorizationChange + locationManagerDidChangeAuthorization.
369
+ }
370
+ }
371
+
372
+ // MARK: - Tracking
373
+
374
+ private func startTracking(options: [String: Any]?) {
375
+ guard !isTracking else { return }
376
+
377
+ let enableBg = (options?["enableBackground"] as? Bool) ?? false
378
+ enableActivity = (options?["enableActivityTracking"] as? Bool) ?? true
379
+ enablePedometer = (options?["enableStepCounting"] as? Bool) ?? true
380
+
381
+ // Location config (delegated to LocationProvider)
382
+ var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
383
+ if let accuracy = options?["accuracy"] as? String {
384
+ switch accuracy {
385
+ case "high": desiredAccuracy = kCLLocationAccuracyBest
386
+ case "balanced": desiredAccuracy = kCLLocationAccuracyNearestTenMeters
387
+ case "low": desiredAccuracy = kCLLocationAccuracyHundredMeters
388
+ case "lowest": desiredAccuracy = kCLLocationAccuracyThreeKilometers
389
+ default: break
390
+ }
391
+ }
392
+ var distanceFilter: CLLocationDistance = kCLDistanceFilterNone
393
+ if let df = options?["distanceFilter"] as? Double, df > 0 {
394
+ distanceFilter = df
395
+ }
396
+ locationProvider.configure(desiredAccuracy: desiredAccuracy, distanceFilter: distanceFilter)
397
+
398
+ // Reset session
399
+ sessionLocations.removeAll()
400
+ sessionActivities.removeAll()
401
+ sessionPedometer.removeAll()
402
+ sessionStartedAt = Date()
403
+ sessionId = UUID().uuidString
404
+
405
+ // Start location (delegated)
406
+ let pauses = (options?["ios"] as? [String: Any])?["pausesLocationUpdatesAutomatically"] as? Bool ?? false
407
+ locationProvider.applyBackgroundAndPauseSettings(allowsBackground: enableBg, pausesAutomatically: pauses)
408
+
409
+ if CLLocationManager.locationServicesEnabled() {
410
+ locationProvider.startUpdatingLocation()
411
+ } else {
412
+ sendError(code: "location_unavailable", message: "Location services are disabled")
413
+ }
414
+
415
+ // Motion / activity / pedometer — delegated
416
+ motionActivityProvider.configure(enableActivity: enableActivity, enablePedometer: enablePedometer)
417
+ motionActivityProvider.onActivityUpdate = { [weak self] dict in
418
+ // Reconstruct minimal record for session/current if needed (preserves prior behavior)
419
+ if let type = dict["type"] as? String, let conf = dict["confidence"] as? Double {
420
+ let rec = ActivityRecord(type: type, confidence: conf, timestamp: Date().timeIntervalSince1970 * 1000)
421
+ self?.currentActivity = rec
422
+ self?.sessionActivities.append(rec)
423
+ }
424
+ self?.sendEvent("onActivityUpdate", dict)
425
+ self?.sendStateChange()
426
+ }
427
+ motionActivityProvider.onPedometerUpdate = { [weak self] dict in
428
+ if let steps = dict["steps"] as? Int {
429
+ let rec = PedometerRecord(
430
+ steps: steps,
431
+ distance: dict["distance"] as? Double,
432
+ floorsAscended: dict["floorsAscended"] as? Int,
433
+ floorsDescended: dict["floorsDescended"] as? Int,
434
+ timestamp: Date().timeIntervalSince1970 * 1000
435
+ )
436
+ self?.sessionPedometer.append(rec)
437
+ }
438
+ self?.sendEvent("onPedometerUpdate", dict)
439
+ }
440
+ motionActivityProvider.onError = { [weak self] code, msg in
441
+ self?.sendError(code: code, message: msg)
442
+ }
443
+ motionActivityProvider.start()
444
+
445
+ isTracking = true
446
+ sendStateChange()
447
+
448
+ // Wire more config from ThermsConfig (smallest; no behavior change).
449
+ // Only schedule.enabled acts; geofence/persistence enabled are comments (use explicit add/start APIs).
450
+ if let sched = options?["schedule"] as? [String: Any], (sched["enabled"] as? Bool) == true {
451
+ scheduleManager.start(config: sched)
452
+ }
453
+ if let _ = options?["geofence"] as? [String: Any] {
454
+ // e.g. (gf["enabled"] as? Bool) could trigger startGeofences but preserve explicit API use
455
+ }
456
+ if let _ = options?["persistence"] as? [String: Any] {
457
+ // LocationStore could consult maxDaysToPersist etc in future; current bounded by maxEntries
458
+ }
459
+ if let syncCfg = options?["sync"] as? [String: Any] {
460
+ syncConfigFromLastStart = syncCfg
461
+ if (syncCfg["enabled"] as? Bool) == true {
462
+ syncManager.start(config: syncCfg)
463
+ }
464
+ }
465
+
466
+ // Fire initial provider state (like reference libs do after ready)
467
+ let prov = self.buildProviderChangeDict()
468
+ self.sendEvent("onProviderChange", prov)
469
+ }
470
+
471
+ private func stopTracking() {
472
+ guard isTracking else { return }
473
+
474
+ // Stop location updates (delegated to provider)
475
+ locationProvider.stopUpdating()
476
+ motionActivityProvider.stop()
477
+
478
+ isTracking = false
479
+ sendStateChange()
480
+
481
+ syncManager.stop()
482
+ syncConfigFromLastStart = nil
483
+ sendEvent("onMotionChange", ["isMoving": false, "location": nil])
484
+ // Note: schedule lifecycle independent (no auto stopSchedule on stopTracking; call explicitly if needed)
485
+ }
486
+
487
+ // MARK: - CLLocationManagerDelegate
488
+
489
+ @objc public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
490
+ guard let location = locations.last else { return }
491
+
492
+ let record = LocationPointRecord(
493
+ latitude: location.coordinate.latitude,
494
+ longitude: location.coordinate.longitude,
495
+ altitude: location.altitude,
496
+ accuracy: location.horizontalAccuracy >= 0 ? location.horizontalAccuracy : nil,
497
+ speed: location.speed >= 0 ? location.speed : nil,
498
+ heading: location.course >= 0 ? location.course : nil,
499
+ timestamp: location.timestamp.timeIntervalSince1970 * 1000
500
+ )
501
+
502
+ lastLocation = record
503
+ sessionLocations.append(record)
504
+
505
+ // Persist via store (adds id automatically)
506
+ locationStore.insert(locationRecordToDict(record))
507
+
508
+ triggerImmediateSyncIfNeeded()
509
+
510
+ sendEvent("onLocationUpdate", locationRecordToDict(record))
511
+ sendEvent("onMotionChange", ["isMoving": true, "location": locationRecordToDict(record)])
512
+ sendStateChange()
513
+ }
514
+
515
+ @objc public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
516
+ sendError(code: "location_error", message: error.localizedDescription)
517
+ }
518
+
519
+ @objc public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
520
+ handleAuthorizationChange()
521
+ }
522
+
523
+ // Older delegate signature (pre-iOS 14 / for compatibility): still invoked by CLLocationManager in some cases.
524
+ // Ensures we capture auth changes for permission request continuations across iOS versions.
525
+ @objc public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
526
+ handleAuthorizationChange()
527
+ }
528
+
529
+ private func handleAuthorizationChange() {
530
+ let event = self.buildProviderChangeDict()
531
+ self.sendEvent("onProviderChange", event)
532
+
533
+ // Resume any in-flight requestPermissionsAsync continuation. This makes permission flow
534
+ // reliable and delegate-driven instead of time-based polling.
535
+ if let cont = pendingPermissionContinuation {
536
+ pendingPermissionContinuation = nil
537
+ cont.resume(returning: self.getPermissionStatuses())
538
+ }
539
+ }
540
+
541
+ // MARK: - Helpers
542
+
543
+
544
+
545
+ private func locationRecordToDict(_ r: LocationPointRecord) -> [String: Any] {
546
+ var dict: [String: Any] = [
547
+ "latitude": r.latitude,
548
+ "longitude": r.longitude,
549
+ "timestamp": r.timestamp,
550
+ ]
551
+ if let v = r.altitude { dict["altitude"] = v }
552
+ if let v = r.accuracy { dict["accuracy"] = v }
553
+ if let v = r.speed { dict["speed"] = v }
554
+ if let v = r.heading { dict["heading"] = v }
555
+ return dict
556
+ }
557
+
558
+ private func activityRecordToDict(_ r: ActivityRecord) -> [String: Any] {
559
+ return ["type": r.type, "confidence": r.confidence, "timestamp": r.timestamp]
560
+ }
561
+
562
+ private func pedometerRecordToDict(_ r: PedometerRecord) -> [String: Any] {
563
+ var d: [String: Any] = ["steps": r.steps, "timestamp": r.timestamp]
564
+ if let v = r.distance { d["distance"] = v }
565
+ if let v = r.floorsAscended { d["floorsAscended"] = v }
566
+ if let v = r.floorsDescended { d["floorsDescended"] = v }
567
+ return d
568
+ }
569
+
570
+ private func buildTrackingStatusDict() -> [String: Any] {
571
+ return [
572
+ "state": isTracking ? "active" : "inactive",
573
+ "isBackground": locationProvider.manager.allowsBackgroundLocationUpdates,
574
+ "sessionId": sessionId as Any,
575
+ "startedAt": sessionStartedAt.map { $0.timeIntervalSince1970 * 1000 } as Any,
576
+ ]
577
+ }
578
+
579
+ private func buildProviderChangeDict() -> [String: Any] {
580
+ // Delegated fully to ProviderMonitor (uses active manager for instance auth + accuracy props).
581
+ return ProviderMonitor.currentState(using: locationProvider.manager)
582
+ }
583
+
584
+ private func sendStateChange() {
585
+ sendEvent("onTrackingStateChange", buildTrackingStatusDict())
586
+ }
587
+
588
+ private func sendError(code: String, message: String) {
589
+ sendEvent("onError", ["code": code, "message": message])
590
+ }
591
+
592
+ // Extracted helper to reduce duplication of the immediate (non-batch) sync trigger.
593
+ // Used from didUpdateLocations and insertLocation. Keeps the check in one place
594
+ // while the module remains a thin coordinator. (Small intentional dupe vs Android mirror.)
595
+ private func triggerImmediateSyncIfNeeded() {
596
+ if let sc = syncConfigFromLastStart,
597
+ (sc["enabled"] as? Bool) == true,
598
+ (sc["batch"] as? Bool) != true {
599
+ syncManager.syncNow()
600
+ }
601
+ }
602
+
603
+ // MARK: - Delegate forwarding to managers (keeps main module thin)
604
+
605
+ @objc public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
606
+ geofenceManager.handleDidEnterRegion(region)
607
+ }
608
+
609
+ @objc public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
610
+ geofenceManager.handleDidExitRegion(region)
611
+ }
612
+
613
+ @objc public func locationManager(_ manager: CLLocationManager, didDetermineState state: CLRegionState, for region: CLRegion) {
614
+ geofenceManager.handleDidDetermineState(state, for: region)
615
+ }
616
+ }
617
+
618
+ // MARK: - SharedObject impl
619
+ // ThermsTracker is a lightweight handle/proxy (not owner of tracking state).
620
+ // State lives in the ThermsDeviceTrackerModule (the one true session).
621
+ // ownerModule allows properties to delegate without ignoring 'ref'.
622
+ // Multiple handles are equivalent and intentionally share state.
623
+
624
+ public class ThermsTracker: SharedObject {
625
+ // Weak ref to owning module for proxying live state. Set by Class constructor.
626
+ public weak var ownerModule: ThermsDeviceTrackerModule?
627
+
628
+ override public func sharedObjectDidRelease() {
629
+ // No per-handle resources; module manages its own session lifecycle.
630
+ super.sharedObjectDidRelease()
631
+ }
632
+ }
@@ -0,0 +1,17 @@
1
+ import ExpoModulesCore
2
+
3
+ /**
4
+ * Legacy compatibility placeholder only.
5
+ * The real live state handle is `ThermsTracker` (defined + registered inside ThermsDeviceTrackerModule.swift).
6
+ * Never instantiated via the module definition; retained only for historical name references.
7
+ * Do not use directly; use `useThermsTracker()` / ThermsTracker from JS.
8
+ */
9
+ public class ThermsDeviceTrackerModuleSharedObject: SharedObject {
10
+ // Unused legacy field; preserved only to avoid any binary compat surprises in old builds.
11
+ @available(*, deprecated, message: "Legacy placeholder; not used by current ThermsTracker implementation.")
12
+ var count: Int = 0
13
+
14
+ override public func sharedObjectDidRelease() {
15
+ super.sharedObjectDidRelease()
16
+ }
17
+ }