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,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
|
+
}
|