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.
- package/ARCHITECTURE.md +145 -0
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +386 -0
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +23 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ActivityRecognitionProvider.kt +109 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceProvider.kt +184 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceTransitionReceiver.kt +34 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/LocationProvider.kt +84 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/LocationStore.kt +150 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ScheduleProvider.kt +55 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/SyncProvider.kt +292 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModule.kt +726 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModuleSharedObject.kt +23 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsLocationService.kt +129 -0
- package/app.plugin.js +1 -0
- package/build/DeviceSettings.d.ts +14 -0
- package/build/DeviceSettings.d.ts.map +1 -0
- package/build/DeviceSettings.js +24 -0
- package/build/DeviceSettings.js.map +1 -0
- package/build/Logger.d.ts +13 -0
- package/build/Logger.d.ts.map +1 -0
- package/build/Logger.js +27 -0
- package/build/Logger.js.map +1 -0
- package/build/NativeModule.d.ts +51 -0
- package/build/NativeModule.d.ts.map +1 -0
- package/build/NativeModule.js +159 -0
- package/build/NativeModule.js.map +1 -0
- package/build/ThermsDeviceTracker.types.d.ts +204 -0
- package/build/ThermsDeviceTracker.types.d.ts.map +1 -0
- package/build/ThermsDeviceTracker.types.js +34 -0
- package/build/ThermsDeviceTracker.types.js.map +1 -0
- package/build/ThermsDeviceTrackerModule.d.ts +43 -0
- package/build/ThermsDeviceTrackerModule.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModule.js +3 -0
- package/build/ThermsDeviceTrackerModule.js.map +1 -0
- package/build/ThermsDeviceTrackerModule.web.d.ts +47 -0
- package/build/ThermsDeviceTrackerModule.web.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModule.web.js +132 -0
- package/build/ThermsDeviceTrackerModule.web.js.map +1 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.d.ts +46 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.js +24 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.js.map +1 -0
- package/build/index.d.ts +101 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +221 -0
- package/build/index.js.map +1 -0
- package/build/plugin/index.d.ts +14 -0
- package/build/plugin/index.d.ts.map +1 -0
- package/build/plugin/index.js +83 -0
- package/build/plugin/index.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -0
- package/expo-module.config.json +9 -0
- package/ios/GeofenceManager.swift +221 -0
- package/ios/LocationProvider.swift +32 -0
- package/ios/LocationStore.swift +98 -0
- package/ios/MotionActivityProvider.swift +109 -0
- package/ios/ProviderMonitor.swift +33 -0
- package/ios/ScheduleManager.swift +33 -0
- package/ios/SyncManager.swift +186 -0
- package/ios/ThermsDeviceTracker.podspec +24 -0
- package/ios/ThermsDeviceTrackerModule.swift +632 -0
- package/ios/ThermsDeviceTrackerModuleSharedObject.swift +17 -0
- package/ios/ThermsGeofenceTests.swift +474 -0
- 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
|
+
}
|