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,726 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.Manifest
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.content.pm.PackageManager
9
+ import android.location.Location
10
+ import android.os.Build
11
+ import android.os.Looper
12
+ import androidx.core.content.ContextCompat
13
+ import androidx.core.os.bundleOf
14
+ import com.google.android.gms.location.*
15
+ import expo.modules.kotlin.AppContext
16
+ import expo.modules.kotlin.modules.Module
17
+ import expo.modules.kotlin.modules.ModuleDefinition
18
+ import java.util.*
19
+
20
+ class ThermsDeviceTrackerModule : Module() {
21
+ // Providers extracted (unification with iOS patterns): LocationProvider, ActivityRecognitionProvider
22
+ // (iOS: MotionActivityProvider), GeofenceProvider (seam unified to callbacks after ctor),
23
+ // ScheduleProvider, SyncProvider, LocationStore.
24
+ // Module is thin coordinator delegating via callbacks (see ThermsDeviceTrackerModule.swift
25
+ // and per-class docs).
26
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
27
+ private lateinit var activityRecognitionClient: ActivityRecognitionClient
28
+ private lateinit var geofencingClient: GeofencingClient
29
+
30
+ private var bgReceiver: android.content.BroadcastReceiver? = null
31
+ private var providerReceiver: android.content.BroadcastReceiver? = null
32
+ private var geofenceReceiver: android.content.BroadcastReceiver? = null
33
+ private var syncReceiver: android.content.BroadcastReceiver? = null
34
+ private var activityReceiver: android.content.BroadcastReceiver? = null
35
+ private var enableBackground = false
36
+
37
+ // Focused provider
38
+ private lateinit var geofenceProvider: GeofenceProvider
39
+ private lateinit var locationStore: LocationStore
40
+ private lateinit var syncProvider: SyncProvider
41
+ private lateinit var scheduleProvider: ScheduleProvider
42
+ private lateinit var locationProvider: LocationProvider
43
+ private lateinit var activityProvider: ActivityRecognitionProvider
44
+
45
+ private var isTracking = false
46
+ private var lastSyncConfig: Map<String, Any?>? = null
47
+ private var enableActivityTracking = true
48
+ private var enableStepCounting = true
49
+ private var sessionLocations = mutableListOf<LocationPointRecord>()
50
+ private var sessionActivities = mutableListOf<ActivityRecord>()
51
+ private var sessionPedometer = mutableListOf<PedometerRecord>()
52
+ private var lastLocation: LocationPointRecord? = null
53
+ private var currentActivity: ActivityRecord? = null
54
+ private var sessionStartedAt: Long? = null
55
+ private var sessionId: String? = null
56
+
57
+ data class LocationPointRecord(
58
+ val latitude: Double,
59
+ val longitude: Double,
60
+ val altitude: Double?,
61
+ val accuracy: Float?,
62
+ val speed: Float?,
63
+ val heading: Float?,
64
+ val timestamp: Double
65
+ )
66
+
67
+ data class ActivityRecord(
68
+ val type: String,
69
+ val confidence: Double,
70
+ val timestamp: Double
71
+ )
72
+
73
+ data class PedometerRecord(
74
+ val steps: Int,
75
+ val distance: Double?,
76
+ val floorsAscended: Int?,
77
+ val floorsDescended: Int?,
78
+ val timestamp: Double
79
+ )
80
+
81
+ private val context: Context
82
+ get() = requireNotNull(appContext.reactContext)
83
+
84
+ override fun definition() = ModuleDefinition {
85
+ Name("ThermsDeviceTracker")
86
+
87
+ Events(
88
+ "onLocationUpdate",
89
+ "onMotionChange",
90
+ "onActivityUpdate",
91
+ "onPedometerUpdate",
92
+ "onTrackingStateChange",
93
+ "onProviderChange",
94
+ "onGeofence",
95
+ "onGeofencesChange",
96
+ "onSchedule",
97
+ "onSync",
98
+ "onError"
99
+ )
100
+
101
+ OnCreate {
102
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
103
+ activityRecognitionClient = ActivityRecognition.getClient(context)
104
+ geofencingClient = LocationServices.getGeofencingClient(context)
105
+
106
+ locationStore = LocationStore(context)
107
+ geofenceProvider = GeofenceProvider(
108
+ context = context,
109
+ geofencingClient = geofencingClient,
110
+ store = locationStore
111
+ )
112
+ // Set callbacks after construction (unified seam; no lambdas in ctor).
113
+ // lastLocationProvider supplies location attachment at transition time (keeps events identical).
114
+ geofenceProvider.onGeofence = { payload -> sendEvent("onGeofence", payload) }
115
+ geofenceProvider.onGeofencesChange = { payload -> sendEvent("onGeofencesChange", payload) }
116
+ geofenceProvider.onError = { code, msg -> sendError(code, msg) }
117
+ geofenceProvider.lastLocationProvider = { lastLocation?.let { toLocationDict(it) } }
118
+
119
+ // Sync seam (injected with store only; onSync callback like iOS SyncManager).
120
+ // All logic (config, WorkManager scheduling, live-preferring fetch in worker, broadcast result handling) lives in SyncProvider.
121
+ // Module is thin: just wires onSync -> sendEvent and registers receiver to delegate to handleSyncResult.
122
+ // (Naming: Provider on Android to match GeofenceProvider; iOS uses SyncManager.)
123
+ syncProvider = SyncProvider(
124
+ context = context,
125
+ store = locationStore
126
+ )
127
+ syncProvider.onSync = { payload -> sendEvent("onSync", payload) }
128
+
129
+ scheduleProvider = ScheduleProvider(context)
130
+ scheduleProvider.onSchedule = { payload -> sendEvent("onSchedule", payload) }
131
+
132
+ locationProvider = LocationProvider(context, fusedLocationClient)
133
+ locationProvider.onNewLocation = { loc -> handleNewLocation(loc) }
134
+ locationProvider.onError = { code, msg -> sendError(code, msg) }
135
+
136
+ activityProvider = ActivityRecognitionProvider(context, activityRecognitionClient)
137
+ activityProvider.onActivityUpdate = { dict -> handleActivityUpdate(dict) }
138
+ activityProvider.onError = { code, msg -> sendError(code, msg) }
139
+
140
+ registerBgReceiver()
141
+ registerProviderReceiver()
142
+ registerGeofenceReceiver() // unconditional (receivers cheap; thin coordinator)
143
+ registerSyncReceiver() // for sync results via broadcast (delegates to provider.handle)
144
+ registerActivityReceiver() // for activity via broadcast from ActivityRecognition PI (delegates to provider.handle)
145
+ // Emit initial provider state
146
+ sendEvent("onProviderChange", buildProviderChangeDict())
147
+ }
148
+
149
+ OnDestroy {
150
+ unregisterBgReceiver()
151
+ unregisterProviderReceiver()
152
+ unregisterGeofenceReceiver()
153
+ unregisterSyncReceiver()
154
+ unregisterActivityReceiver()
155
+ syncProvider.stop()
156
+ scheduleProvider.stop()
157
+ // activityProvider.stop() will be called via stopTracking if active; safe to call here too
158
+ activityProvider.stop()
159
+ }
160
+
161
+ // Permissions
162
+ AsyncFunction("getPermissionsAsync") {
163
+ getPermissionStatuses()
164
+ }
165
+
166
+ AsyncFunction("requestPermissionsAsync") { options: Map<String, Any?>? ->
167
+ val background = options?.get("background") as? Boolean ?: false
168
+ requestPermissions(background)
169
+ }
170
+
171
+ // Tracking
172
+ AsyncFunction("startTrackingAsync") { options: Map<String, Any?>? ->
173
+ startTracking(options)
174
+ }
175
+
176
+ AsyncFunction("stopTrackingAsync") {
177
+ stopTracking()
178
+ }
179
+
180
+ // Getters
181
+ AsyncFunction("getCurrentLocationAsync") {
182
+ lastLocation?.let { toLocationDict(it) }
183
+ }
184
+
185
+ Function("getLastKnownLocation") {
186
+ lastLocation?.let { toLocationDict(it) }
187
+ }
188
+
189
+ Function("getCurrentActivity") {
190
+ currentActivity?.let { toActivityDict(it) }
191
+ }
192
+
193
+ Function("getTrackingStatus") {
194
+ buildStatusDict()
195
+ }
196
+
197
+ Function("getProviderState") {
198
+ buildProviderChangeDict()
199
+ }
200
+
201
+ // Geofencing (delegated)
202
+ AsyncFunction("addGeofence") { geofence: Map<String, Any?> ->
203
+ geofenceProvider.addGeofence(geofence)
204
+ }
205
+
206
+ AsyncFunction("addGeofences") { geofences: List<Map<String, Any?>> ->
207
+ geofenceProvider.addGeofences(geofences)
208
+ }
209
+
210
+ AsyncFunction("removeGeofence") { identifier: String ->
211
+ geofenceProvider.removeGeofence(identifier)
212
+ }
213
+
214
+ AsyncFunction("removeGeofences") { identifiers: List<String>? ->
215
+ geofenceProvider.removeGeofences(identifiers)
216
+ }
217
+
218
+ AsyncFunction("getGeofences") {
219
+ geofenceProvider.getGeofences()
220
+ }
221
+
222
+ AsyncFunction("startGeofences") {
223
+ geofenceProvider.startMonitoring() // restore+register inside (parity with iOS explicit in startGeofences)
224
+ }
225
+
226
+ // Persistence (delegated to extracted LocationStore using SharedPrefs w/ id support)
227
+ AsyncFunction("getLocations") {
228
+ locationStore.getAll() + sessionLocations.map { toLocationDict(it) }
229
+ }
230
+
231
+ AsyncFunction("getCount") {
232
+ locationStore.count() + sessionLocations.size
233
+ }
234
+
235
+ AsyncFunction("destroyLocations") {
236
+ locationStore.clear()
237
+ sessionLocations.clear()
238
+ }
239
+
240
+ AsyncFunction("destroyLocation") { uuid: String? ->
241
+ if (uuid != null) locationStore.remove(uuid) else locationStore.clear()
242
+ }
243
+
244
+ AsyncFunction("insertLocation") { loc: Map<String, Any?> ->
245
+ locationStore.insert(loc)
246
+ triggerImmediateSyncIfNeeded()
247
+ }
248
+
249
+ // Scheduler (delegated to focused provider for parity with iOS ScheduleManager)
250
+ AsyncFunction("startSchedule") {
251
+ scheduleProvider.start()
252
+ }
253
+
254
+ AsyncFunction("stopSchedule") {
255
+ scheduleProvider.stop()
256
+ }
257
+
258
+ // Opt-in native bg sync (delegates to SyncProvider; onSync wired in OnCreate).
259
+ // Mirror iOS: if we have a prior config from ready/start use it; otherwise fall back to syncNow()
260
+ // (useful after explicit stopSync or for immediate trigger when no last config).
261
+ AsyncFunction("startSync") {
262
+ if (lastSyncConfig != null) {
263
+ syncProvider.start(lastSyncConfig)
264
+ } else {
265
+ syncProvider.syncNow()
266
+ }
267
+ }
268
+
269
+ AsyncFunction("stopSync") {
270
+ syncProvider.stop()
271
+ }
272
+
273
+ AsyncFunction("getCurrentSessionHistory") {
274
+ mapOf(
275
+ "locations" to sessionLocations.map { toLocationDict(it) },
276
+ "activities" to sessionActivities.map { toActivityDict(it) },
277
+ "pedometer" to sessionPedometer.map { toPedometerDict(it) }
278
+ )
279
+ }
280
+
281
+ AsyncFunction("clearCurrentSessionHistory") {
282
+ sessionLocations.clear()
283
+ sessionActivities.clear()
284
+ sessionPedometer.clear()
285
+ }
286
+
287
+ Class(ThermsTracker::class) {
288
+ // SharedObject for live state: intentionally proxies the module singleton state
289
+ // (one tracking session). All created handles are equivalent. ownerModule enables
290
+ // using the 'ref' param in properties (cleanup from previous "dummy/ignore ref" impl).
291
+ // Matches iOS and documented public surface in useThermsTracker.
292
+ Constructor {
293
+ val tracker = ThermsTracker(appContext)
294
+ tracker.ownerModule = this@ThermsDeviceTrackerModule
295
+ tracker
296
+ }
297
+
298
+ Property("isTracking") { ref: ThermsTracker -> ref.ownerModule?.isTracking ?: isTracking }
299
+
300
+ Property("trackingStatus") { ref: ThermsTracker -> ref.ownerModule?.buildStatusDict() ?: buildStatusDict() }
301
+
302
+ Property("lastLocation") { ref: ThermsTracker ->
303
+ val m = ref.ownerModule ?: this@ThermsDeviceTrackerModule
304
+ m.lastLocation?.let { m.toLocationDict(it) }
305
+ }
306
+
307
+ Property("currentActivity") { ref: ThermsTracker ->
308
+ val m = ref.ownerModule ?: this@ThermsDeviceTrackerModule
309
+ m.currentActivity?.let { m.toActivityDict(it) }
310
+ }
311
+
312
+ Property("sessionStats") { ref: ThermsTracker ->
313
+ val m = ref.ownerModule ?: this@ThermsDeviceTrackerModule
314
+ m.sessionStartedAt?.let {
315
+ mapOf(
316
+ "startTime" to it,
317
+ "pointCount" to m.sessionLocations.size,
318
+ "totalSteps" to (m.sessionPedometer.lastOrNull()?.steps ?: 0)
319
+ )
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // MARK: Permissions
326
+
327
+ private fun getPermissionStatuses(): Map<String, Any> {
328
+ val fine = checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)
329
+ val coarse = checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
330
+ val activity = checkPermission(Manifest.permission.ACTIVITY_RECOGNITION)
331
+ val bg = checkPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
332
+
333
+ val locGranted = fine == "granted" || coarse == "granted"
334
+
335
+ // Report "denied" (not "undetermined") for !granted to match iOS behavior after fixes
336
+ // (iOS distinguishes notDetermined vs denied/restricted). Background uses separate perm check.
337
+ // canAskAgain kept true (accurate "can re-prompt" requires rationale tracking or prior state).
338
+ return mapOf(
339
+ "location" to if (locGranted) "granted" else "denied",
340
+ "backgroundLocation" to bg,
341
+ "motion" to activity,
342
+ "canAskAgain" to true
343
+ )
344
+ }
345
+
346
+ private fun checkPermission(perm: String): String {
347
+ val res = ContextCompat.checkSelfPermission(context, perm)
348
+ return if (res == PackageManager.PERMISSION_GRANTED) "granted" else "denied"
349
+ }
350
+
351
+ private fun requestPermissions(background: Boolean): Map<String, Any> {
352
+ // NOTE: background option is accepted for API parity (status response will include backgroundLocation).
353
+ // Actual permission prompting in Expo modules typically requires ActivityResultContracts or host/JS layer
354
+ // (e.g. PermissionsAndroid or expo's permission helpers) because dialogs are async + activity-bound.
355
+ // We return accurate current statuses (no regression). Delegate observation pattern used on iOS side.
356
+ return getPermissionStatuses()
357
+ }
358
+
359
+ // MARK: Tracking
360
+
361
+ private fun startTracking(options: Map<String, Any?>?) {
362
+ if (isTracking) return
363
+
364
+ enableActivityTracking = (options?.get("enableActivityTracking") as? Boolean) ?: true
365
+ enableStepCounting = (options?.get("enableStepCounting") as? Boolean) ?: true
366
+ enableBackground = (options?.get("enableBackground") as? Boolean) ?: false
367
+
368
+ // Reset session
369
+ sessionLocations.clear()
370
+ sessionActivities.clear()
371
+ sessionPedometer.clear()
372
+ sessionStartedAt = System.currentTimeMillis()
373
+ sessionId = UUID.randomUUID().toString()
374
+
375
+ // Location request (used for foreground path)
376
+ val accuracyStr = options?.get("accuracy") as? String
377
+ val priority = when (accuracyStr) {
378
+ "high" -> Priority.PRIORITY_HIGH_ACCURACY
379
+ "low" -> Priority.PRIORITY_LOW_POWER
380
+ "lowest" -> Priority.PRIORITY_PASSIVE
381
+ else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
382
+ }
383
+
384
+ val interval = ((options?.get("timeInterval") as? Number)?.toLong() ?: 5000L)
385
+ val minDistance = ((options?.get("distanceFilter") as? Number)?.toFloat() ?: 0f)
386
+
387
+ if (enableBackground) {
388
+ // Delegate to foreground service (bg path)
389
+ val serviceIntent = Intent(context, ThermsLocationService::class.java).apply {
390
+ action = ThermsLocationService.ACTION_START
391
+ }
392
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
393
+ context.startForegroundService(serviceIntent)
394
+ } else {
395
+ context.startService(serviceIntent)
396
+ }
397
+ } else {
398
+ // Foreground-only: delegate config + start to LocationProvider
399
+ locationProvider.configure(priority, interval, minDistance)
400
+ if (hasLocationPermission()) {
401
+ locationProvider.startUpdatingLocation()
402
+ } else {
403
+ sendError("permission_denied", "Location permission not granted")
404
+ }
405
+ }
406
+
407
+ // Wire more config from ThermsConfig (smallest; no behavior change).
408
+ // Only schedule.enabled has side effect here (delegated to ScheduleProvider); other sections use explicit APIs.
409
+ val sched = options?.get("schedule") as? Map<*, *>
410
+ if (sched?.get("enabled") as? Boolean == true) {
411
+ scheduleProvider.start(sched as? Map<String, Any?>)
412
+ }
413
+ val gfCfg = options?.get("geofence") as? Map<*, *>
414
+ if (gfCfg?.get("enabled") as? Boolean == true) {
415
+ // geofenceProvider could auto-start; keep explicit for API parity
416
+ }
417
+ if (options?.get("persistence") != null) {
418
+ // locationStore ready; maxDays etc stub in store for future
419
+ }
420
+ val syncCfg = options?.get("sync") as? Map<*, *>
421
+ if (syncCfg?.get("enabled") as? Boolean == true) {
422
+ lastSyncConfig = syncCfg as Map<String, Any?>?
423
+ syncProvider.start(lastSyncConfig)
424
+ }
425
+
426
+ // Activity recognition now delegated to ActivityRecognitionProvider (real updates, callback seam).
427
+ // Mirrors LocationProvider pattern and iOS MotionActivityProvider wiring.
428
+ // Provider uses PendingIntent + ACTION; module receiver forwards to handleActivityResult -> onActivityUpdate.
429
+ // Placeholder emission removed; real detections arrive via callback when updates fire.
430
+ if (enableActivityTracking) {
431
+ if (hasActivityPermission()) {
432
+ activityProvider.configure(enableActivity = true)
433
+ activityProvider.start()
434
+ } else {
435
+ sendError("permission_denied", "Activity recognition permission not granted")
436
+ }
437
+ }
438
+
439
+ isTracking = true
440
+ sendStateChange()
441
+ }
442
+
443
+ private fun stopTracking() {
444
+ if (!isTracking) return
445
+
446
+ if (enableBackground) {
447
+ val stopIntent = Intent(context, ThermsLocationService::class.java).apply {
448
+ action = ThermsLocationService.ACTION_STOP
449
+ }
450
+ context.startService(stopIntent)
451
+ } else {
452
+ locationProvider.stopUpdatingLocation()
453
+ }
454
+
455
+ // Delegate stop to activity provider (idempotent; clears pending intent + removes updates)
456
+ activityProvider.stop()
457
+
458
+ isTracking = false
459
+ enableBackground = false
460
+ sendStateChange()
461
+
462
+ syncProvider.stop()
463
+ lastSyncConfig = null
464
+ sendEvent("onMotionChange", mapOf("isMoving" to false, "location" to nil))
465
+ // Note: schedule lifecycle independent (no auto stopSchedule on stopTracking; call explicitly if needed)
466
+ }
467
+
468
+ private fun registerBgReceiver() {
469
+ if (bgReceiver != null) return
470
+ bgReceiver = object : BroadcastReceiver() {
471
+ override fun onReceive(ctx: Context?, intent: Intent?) {
472
+ if (intent?.action == ThermsLocationService.BROADCAST_ACTION) {
473
+ val lat = intent.getDoubleExtra(ThermsLocationService.EXTRA_LAT, 0.0)
474
+ val lon = intent.getDoubleExtra(ThermsLocationService.EXTRA_LON, 0.0)
475
+ val acc = intent.getFloatExtra(ThermsLocationService.EXTRA_ACCURACY, -1f)
476
+ val spd = intent.getFloatExtra(ThermsLocationService.EXTRA_SPEED, -1f)
477
+ val ts = intent.getLongExtra(ThermsLocationService.EXTRA_TIMESTAMP, System.currentTimeMillis())
478
+
479
+ val record = LocationPointRecord(
480
+ latitude = lat,
481
+ longitude = lon,
482
+ altitude = null,
483
+ accuracy = if (acc >= 0) acc else null,
484
+ speed = if (spd >= 0) spd else null,
485
+ heading = null,
486
+ timestamp = ts.toDouble()
487
+ )
488
+ lastLocation = record
489
+ sessionLocations.add(record)
490
+ locationStore.insert(toLocationDict(record))
491
+ sendEvent("onLocationUpdate", toLocationDict(record))
492
+ sendEvent("onMotionChange", mapOf("isMoving" to true, "location" to toLocationDict(record)))
493
+ triggerImmediateSyncIfNeeded()
494
+ sendStateChange()
495
+ }
496
+ }
497
+ }
498
+ val filter = IntentFilter(ThermsLocationService.BROADCAST_ACTION)
499
+ context.registerReceiver(bgReceiver, filter)
500
+ }
501
+
502
+ private fun unregisterBgReceiver() {
503
+ bgReceiver?.let {
504
+ try { context.unregisterReceiver(it) } catch (_: Exception) {}
505
+ }
506
+ bgReceiver = null
507
+ }
508
+
509
+ private fun registerProviderReceiver() {
510
+ if (providerReceiver != null) return
511
+ providerReceiver = object : BroadcastReceiver() {
512
+ override fun onReceive(ctx: Context?, intent: Intent?) {
513
+ if (intent?.action == android.location.LocationManager.PROVIDERS_CHANGED_ACTION) {
514
+ sendEvent("onProviderChange", buildProviderChangeDict())
515
+ }
516
+ }
517
+ }
518
+ val filter = IntentFilter(android.location.LocationManager.PROVIDERS_CHANGED_ACTION)
519
+ context.registerReceiver(providerReceiver, filter)
520
+ }
521
+
522
+ private fun unregisterProviderReceiver() {
523
+ providerReceiver?.let {
524
+ try { context.unregisterReceiver(it) } catch (_: Exception) {}
525
+ }
526
+ providerReceiver = null
527
+ }
528
+
529
+ private fun handleNewLocation(location: Location) {
530
+ val record = LocationPointRecord(
531
+ latitude = location.latitude,
532
+ longitude = location.longitude,
533
+ altitude = if (location.hasAltitude()) location.altitude else null,
534
+ accuracy = if (location.hasAccuracy()) location.accuracy else null,
535
+ speed = if (location.hasSpeed()) location.speed else null,
536
+ heading = if (location.hasBearing()) location.bearing else null,
537
+ timestamp = location.time.toDouble()
538
+ )
539
+ lastLocation = record
540
+ sessionLocations.add(record)
541
+
542
+ locationStore.insert(toLocationDict(record))
543
+
544
+ triggerImmediateSyncIfNeeded()
545
+
546
+ sendEvent("onLocationUpdate", toLocationDict(record))
547
+ sendEvent("onMotionChange", mapOf("isMoving" to true, "location" to toLocationDict(record)))
548
+ sendStateChange()
549
+ }
550
+
551
+ private fun handleActivityUpdate(dict: Map<String, Any>) {
552
+ val type = dict["type"] as? String ?: "unknown"
553
+ val confidence = dict["confidence"] as? Double ?: 0.0
554
+ val timestamp = dict["timestamp"] as? Double ?: System.currentTimeMillis().toDouble()
555
+
556
+ val record = ActivityRecord(type = type, confidence = confidence, timestamp = timestamp)
557
+ currentActivity = record
558
+ sessionActivities.add(record)
559
+
560
+ sendEvent("onActivityUpdate", dict)
561
+ sendStateChange()
562
+ }
563
+
564
+ private fun hasLocationPermission(): Boolean {
565
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
566
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
567
+ }
568
+
569
+ private fun hasActivityPermission(): Boolean {
570
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED
571
+ }
572
+
573
+ // MARK: Serialization
574
+
575
+ private fun toLocationDict(r: LocationPointRecord): Map<String, Any?> =
576
+ mapOf(
577
+ "latitude" to r.latitude,
578
+ "longitude" to r.longitude,
579
+ "altitude" to r.altitude,
580
+ "accuracy" to r.accuracy,
581
+ "speed" to r.speed,
582
+ "heading" to r.heading,
583
+ "timestamp" to r.timestamp
584
+ )
585
+
586
+ private fun toActivityDict(r: ActivityRecord) = mapOf(
587
+ "type" to r.type,
588
+ "confidence" to r.confidence,
589
+ "timestamp" to r.timestamp
590
+ )
591
+
592
+ private fun toPedometerDict(r: PedometerRecord) = mapOf(
593
+ "steps" to r.steps,
594
+ "distance" to r.distance,
595
+ "floorsAscended" to r.floorsAscended,
596
+ "floorsDescended" to r.floorsDescended,
597
+ "timestamp" to r.timestamp
598
+ )
599
+
600
+ private fun buildStatusDict(): Map<String, Any?> = mapOf(
601
+ "state" to if (isTracking) "active" else "inactive",
602
+ "isBackground" to false, // updated when bg service is wired
603
+ "sessionId" to sessionId,
604
+ "startedAt" to sessionStartedAt
605
+ )
606
+
607
+ private fun buildProviderChangeDict(): Map<String, Any> {
608
+ val lm = context.getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
609
+ val gps = lm.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER)
610
+ val net = lm.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER)
611
+ val enabled = gps || net
612
+
613
+ // Simple status mapping (can be enhanced with permission checks)
614
+ val status = if (enabled) "always" else "denied"
615
+
616
+ return mapOf(
617
+ "enabled" to enabled,
618
+ "status" to status,
619
+ "gps" to gps,
620
+ "network" to net
621
+ )
622
+ }
623
+
624
+ private fun sendStateChange() {
625
+ sendEvent("onTrackingStateChange", buildStatusDict())
626
+ }
627
+
628
+ private fun sendError(code: String, message: String) {
629
+ sendEvent("onError", bundleOf("code" to code, "message" to message))
630
+ }
631
+
632
+ /**
633
+ * Extracted to eliminate duplication of the immediate (non-batch) sync trigger.
634
+ * Called after location inserts from native delivery and from the public insertLocation API.
635
+ * Mirrors the iOS triggerImmediateSyncIfNeeded helper (intentional small dupe to keep modules thin + focused).
636
+ */
637
+ private fun triggerImmediateSyncIfNeeded() {
638
+ if (lastSyncConfig?.get("enabled") as? Boolean == true &&
639
+ lastSyncConfig?.get("batch") as? Boolean != true) {
640
+ syncProvider.syncNow()
641
+ }
642
+ }
643
+
644
+ // GeofenceProvider owns the state and logic (see GeofenceProvider.kt)
645
+
646
+ // Geofence receiver registration (dispatches to onGeofence)
647
+ private fun registerGeofenceReceiver() {
648
+ if (geofenceReceiver != null) return
649
+ geofenceReceiver = object : BroadcastReceiver() {
650
+ override fun onReceive(ctx: Context?, intent: Intent?) {
651
+ if (intent?.action != GeofenceTransitionReceiver.ACTION) return
652
+ val transition = intent.getIntExtra(GeofenceTransitionReceiver.EXTRA_TRANSITION, -1)
653
+ val ids = intent.getStringArrayListExtra(GeofenceTransitionReceiver.EXTRA_IDS) ?: return
654
+ geofenceProvider.handleTransition(transition, ids)
655
+ }
656
+ }
657
+ context.registerReceiver(geofenceReceiver, IntentFilter(GeofenceTransitionReceiver.ACTION))
658
+ }
659
+
660
+ private fun unregisterGeofenceReceiver() {
661
+ geofenceReceiver?.let {
662
+ try { context.unregisterReceiver(it) } catch (_: Exception) {}
663
+ }
664
+ geofenceReceiver = null
665
+ }
666
+
667
+ // Sync result receiver (thin wiring only; delegates to SyncProvider.handleSyncResult which invokes onSync callback).
668
+ // Mirrors geofence broadcast pattern. No payload logic here. Provider owns emission shape.
669
+ private fun registerSyncReceiver() {
670
+ if (syncReceiver != null) return
671
+ syncReceiver = object : BroadcastReceiver() {
672
+ override fun onReceive(ctx: Context?, intent: Intent?) {
673
+ if (intent?.action != SyncProvider.SYNC_RESULT_ACTION) return
674
+ syncProvider.handleSyncResult(intent)
675
+ }
676
+ }
677
+ context.registerReceiver(syncReceiver, IntentFilter(SyncProvider.SYNC_RESULT_ACTION))
678
+ }
679
+
680
+ private fun unregisterSyncReceiver() {
681
+ syncReceiver?.let {
682
+ try { context.unregisterReceiver(it) } catch (_: Exception) {}
683
+ }
684
+ syncReceiver = null
685
+ }
686
+
687
+ // Activity receiver: listens for the broadcast fired by the PendingIntent registered with ActivityRecognitionClient.
688
+ // Delegates to activityProvider.handleActivityResult (which invokes the onActivityUpdate callback).
689
+ // Keeps provider thin (no registration or sendEvent inside provider). Matches geofence/sync receiver patterns.
690
+ private fun registerActivityReceiver() {
691
+ if (activityReceiver != null) return
692
+ activityReceiver = object : BroadcastReceiver() {
693
+ override fun onReceive(ctx: Context?, intent: Intent?) {
694
+ if (intent?.action == ActivityRecognitionProvider.ACTION_ACTIVITY_UPDATE) {
695
+ activityProvider.handleActivityResult(intent)
696
+ }
697
+ }
698
+ }
699
+ val filter = IntentFilter(ActivityRecognitionProvider.ACTION_ACTIVITY_UPDATE)
700
+ context.registerReceiver(activityReceiver, filter)
701
+ }
702
+
703
+ private fun unregisterActivityReceiver() {
704
+ activityReceiver?.let {
705
+ try { context.unregisterReceiver(it) } catch (_: Exception) {}
706
+ }
707
+ activityReceiver = null
708
+ }
709
+
710
+ // Scheduler logic now lives in the focused ScheduleProvider (see OnCreate wiring and start/stopSchedule).
711
+ // The provider mirrors iOS ScheduleManager's callback seam and config interval support.
712
+ }
713
+
714
+ // Lightweight shared object handle (facade / proxy to shared module state).
715
+ // See design notes in Class(ThermsTracker) + JS ThermsDeviceTrackerModuleSharedObject.ts.
716
+ // All ThermsTracker instances share the single tracking session state owned by the module.
717
+ // We set ownerModule so Property handlers can use the 'ref' (instead of ignoring it).
718
+ class ThermsTracker(appContext: AppContext) : expo.modules.kotlin.sharedobjects.SharedObject(appContext) {
719
+ // Nullable ref to owning module (set at construction time in Class block). No strong cycle.
720
+ var ownerModule: ThermsDeviceTrackerModule? = null
721
+
722
+ override fun sharedObjectDidRelease() {
723
+ // No per-handle cleanup needed; module owns session resources + receivers.
724
+ super.sharedObjectDidRelease()
725
+ }
726
+ }