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,109 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.os.Build
7
+ import com.google.android.gms.location.*
8
+
9
+ /**
10
+ * Focused provider for activity recognition (Android ActivityRecognition API).
11
+ *
12
+ * Mirrors iOS MotionActivityProvider (activity portion) + the style of LocationProvider + other *Provider on Android.
13
+ *
14
+ * Responsibilities:
15
+ * - Configure based on enableActivity flag.
16
+ * - Start/stop activity updates using ActivityRecognitionClient + PendingIntent.
17
+ * - Parse DetectedActivity to simple map (type, confidence[0-1], timestamp).
18
+ * - Emit via `onActivityUpdate` callback only — module wires sendEvent / state.
19
+ *
20
+ * Seam: injection of client for testability; callback only (no direct sendEvent).
21
+ * Uses broadcast action + dynamic receiver pattern in module (consistent with geofence/sync).
22
+ * Note: pedometer parity (Android data-only: PedometerRecord + enableStepCounting + session history for API shape;
23
+ * full live support + onPedometerUpdate via CMPedometer in iOS MotionActivityProvider). Full Android impl future.
24
+ */
25
+ class ActivityRecognitionProvider(
26
+ private val context: Context,
27
+ private val client: ActivityRecognitionClient? = null
28
+ ) {
29
+ var onActivityUpdate: ((Map<String, Any>) -> Unit)? = null
30
+ var onError: ((String, String) -> Unit)? = null
31
+
32
+ private val activityClient: ActivityRecognitionClient by lazy {
33
+ client ?: ActivityRecognition.getClient(context)
34
+ }
35
+
36
+ private var enableActivity = true
37
+ private var activityPendingIntent: PendingIntent? = null
38
+
39
+ companion object {
40
+ const val ACTION_ACTIVITY_UPDATE = "expo.modules.thermsdevicetracker.ACTIVITY_UPDATE"
41
+ }
42
+
43
+ fun configure(enableActivity: Boolean) {
44
+ this.enableActivity = enableActivity
45
+ }
46
+
47
+ fun start() {
48
+ if (!enableActivity) return
49
+
50
+ val intent = Intent(ACTION_ACTIVITY_UPDATE).apply {
51
+ setPackage(context.packageName)
52
+ }
53
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
54
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
55
+ } else {
56
+ PendingIntent.FLAG_UPDATE_CURRENT
57
+ }
58
+ val pi = PendingIntent.getBroadcast(context, 0, intent, flags)
59
+ activityPendingIntent = pi
60
+
61
+ try {
62
+ activityClient.requestActivityUpdates(10000L, pi)
63
+ .addOnFailureListener { e ->
64
+ onError?.invoke("activity_error", e.message ?: "Failed to request activity updates")
65
+ }
66
+ } catch (e: SecurityException) {
67
+ onError?.invoke("permission_denied", e.message ?: "Activity recognition permission required")
68
+ }
69
+ }
70
+
71
+ fun stop() {
72
+ activityPendingIntent?.let { pi ->
73
+ try {
74
+ activityClient.removeActivityUpdates(pi)
75
+ } catch (_: Exception) {}
76
+ }
77
+ activityPendingIntent = null
78
+ }
79
+
80
+ /**
81
+ * Called by module's registered broadcast receiver when the PendingIntent fires.
82
+ * Parses result and invokes onActivityUpdate callback (seam consistent).
83
+ */
84
+ fun handleActivityResult(intent: Intent) {
85
+ if (intent.action != ACTION_ACTIVITY_UPDATE) return
86
+ val result = ActivityRecognitionResult.extractResult(intent) ?: return
87
+ val mostProbable = result.mostProbableActivity ?: return
88
+
89
+ val type = when (mostProbable.type) {
90
+ DetectedActivity.STILL -> "still"
91
+ DetectedActivity.WALKING, DetectedActivity.ON_FOOT -> "walking"
92
+ DetectedActivity.RUNNING -> "running"
93
+ DetectedActivity.ON_BICYCLE -> "cycling"
94
+ DetectedActivity.IN_VEHICLE -> "automotive"
95
+ else -> "unknown"
96
+ }
97
+ // Normalize 0-100 -> ~0-1 to align with iOS confidence scale used in dicts
98
+ val confidence = mostProbable.confidence / 100.0
99
+ val timestamp = result.time.toDouble()
100
+
101
+ onActivityUpdate?.invoke(
102
+ mapOf(
103
+ "type" to type,
104
+ "confidence" to confidence,
105
+ "timestamp" to timestamp
106
+ )
107
+ )
108
+ }
109
+ }
@@ -0,0 +1,184 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.content.Context
4
+ import com.google.android.gms.location.Geofence
5
+ import com.google.android.gms.location.GeofencingClient
6
+ import com.google.android.gms.location.GeofencingRequest
7
+ import com.google.android.gms.location.LocationServices
8
+
9
+ /**
10
+ * Focused provider for geofence management.
11
+ * Extracted from the main module to follow separation of concerns (similar to iOS GeofenceManager).
12
+ *
13
+ * Seam: callbacks (onGeofence, onGeofencesChange, onError, lastLocationProvider) are set AFTER construction
14
+ * (see ThermsDeviceTrackerModule). Mirrors LocationProvider / SyncProvider / ScheduleProvider / ActivityRecognitionProvider.
15
+ * No send* or get* lambdas in ctor.
16
+ */
17
+ class GeofenceProvider(
18
+ private val context: Context,
19
+ private val geofencingClient: GeofencingClient,
20
+ private val store: LocationStore? = null // for geofence durability (persist/restore regions via load/saveGeofences)
21
+ ) {
22
+ private val activeGeofences = mutableMapOf<String, Map<String, Any?>>()
23
+
24
+ // Callback seam (set by owner after ctor; consistent style)
25
+ var onGeofence: ((Map<String, Any?>) -> Unit)? = null
26
+ var onGeofencesChange: ((Map<String, Any?>) -> Unit)? = null
27
+ var onError: ((String, String) -> Unit)? = null
28
+ var lastLocationProvider: (() -> Map<String, Any?>?)? = null
29
+
30
+ init {
31
+ // Stub: could auto-restore here, but caller controls (e.g. on startMonitoring)
32
+ }
33
+
34
+ fun addGeofence(map: Map<String, Any?>) {
35
+ val id = map["identifier"] as? String ?: return // silent no-op on bad input (consistent prior)
36
+ val lat = (map["latitude"] as? Number)?.toDouble() ?: return
37
+ val lon = (map["longitude"] as? Number)?.toDouble() ?: return
38
+ val radius = (map["radius"] as? Number)?.toFloat() ?: 100f
39
+
40
+ val notifyEntry = (map["notifyOnEntry"] as? Boolean) ?: true
41
+ val notifyExit = (map["notifyOnExit"] as? Boolean) ?: true
42
+
43
+ registerGeofence(id, lat, lon, radius, notifyEntry, notifyExit, map)
44
+
45
+ persistGeofences()
46
+ notifyChange()
47
+ }
48
+
49
+ // Internal: performs active set + client registration (no per-call persist/notify).
50
+ // Used by addGeofence (public) and startMonitoring bulk (to avoid redundant events/persists).
51
+ private fun registerGeofence(
52
+ id: String, lat: Double, lon: Double, radius: Float,
53
+ notifyEntry: Boolean, notifyExit: Boolean, map: Map<String, Any?>
54
+ ) {
55
+ val geofence = Geofence.Builder()
56
+ .setRequestId(id)
57
+ .setCircularRegion(lat, lon, radius)
58
+ .setExpirationDuration(Geofence.NEVER_EXPIRE)
59
+ .setTransitionTypes(
60
+ (if (notifyEntry) Geofence.GEOFENCE_TRANSITION_ENTER else 0) or
61
+ (if (notifyExit) Geofence.GEOFENCE_TRANSITION_EXIT else 0) or
62
+ Geofence.GEOFENCE_TRANSITION_DWELL
63
+ )
64
+ .setLoiteringDelay((map["loiteringDelay"] as? Number)?.toInt() ?: 30000)
65
+ .build()
66
+
67
+ val request = GeofencingRequest.Builder()
68
+ .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER or GeofencingRequest.INITIAL_TRIGGER_EXIT)
69
+ .addGeofence(geofence)
70
+ .build()
71
+
72
+ activeGeofences[id] = map
73
+
74
+ try {
75
+ geofencingClient.addGeofences(request, createPendingIntent())
76
+ .addOnFailureListener { e -> onError?.invoke("geofence_error", e.message ?: "add failed") }
77
+ } catch (e: SecurityException) {
78
+ onError?.invoke("permission_denied", e.message ?: "geofence perm")
79
+ }
80
+ // Permission ensured by module (hasLocationPermission) + Security catch; geofence requires bg/fine.
81
+ }
82
+
83
+ fun addGeofences(list: List<Map<String, Any?>>) {
84
+ list.forEach { addGeofence(it) }
85
+ }
86
+
87
+ fun removeGeofence(id: String) {
88
+ activeGeofences.remove(id)
89
+ try {
90
+ geofencingClient.removeGeofences(listOf(id))
91
+ } catch (_: Exception) {} // Permission via module + caller; tolerate errors (pre-extract behavior)
92
+ persistGeofences()
93
+ notifyChange()
94
+ }
95
+
96
+ fun removeGeofences(ids: List<String>?) {
97
+ if (ids != null) {
98
+ ids.forEach { removeGeofence(it) }
99
+ } else {
100
+ val all = activeGeofences.keys.toList()
101
+ activeGeofences.clear()
102
+ if (all.isNotEmpty()) {
103
+ try { geofencingClient.removeGeofences(all) } catch (_: Exception) {} // consistent error tolerance
104
+ }
105
+ persistGeofences()
106
+ notifyChange()
107
+ }
108
+ }
109
+
110
+ fun getGeofences(): List<Map<String, Any?>> = activeGeofences.values.toList()
111
+
112
+ fun startMonitoring() {
113
+ // Improve durability: restore populates only (no side effects).
114
+ restorePersistedGeofences()
115
+ // Re-register clients in bulk; side effects (persist/notify) done once after to avoid multiples.
116
+ val toRegister = activeGeofences.values.toList()
117
+ toRegister.forEach { m ->
118
+ val id = m["identifier"] as? String ?: return@forEach
119
+ val lat = (m["latitude"] as? Number)?.toDouble() ?: return@forEach
120
+ val lon = (m["longitude"] as? Number)?.toDouble() ?: return@forEach
121
+ val radius = (m["radius"] as? Number)?.toFloat() ?: 100f
122
+ val entry = (m["notifyOnEntry"] as? Boolean) ?: true
123
+ val exit = (m["notifyOnExit"] as? Boolean) ?: true
124
+ registerGeofence(id, lat, lon, radius, entry, exit, m)
125
+ }
126
+ if (toRegister.isNotEmpty()) {
127
+ persistGeofences()
128
+ notifyChange()
129
+ }
130
+ }
131
+
132
+ // Called by the receiver (via module) when a transition occurs
133
+ fun handleTransition(transition: Int, ids: List<String>) {
134
+ val action = when (transition) {
135
+ Geofence.GEOFENCE_TRANSITION_ENTER -> "ENTER"
136
+ Geofence.GEOFENCE_TRANSITION_EXIT -> "EXIT"
137
+ Geofence.GEOFENCE_TRANSITION_DWELL -> "DWELL"
138
+ else -> "UNKNOWN"
139
+ }
140
+
141
+ ids.forEach { id ->
142
+ val base = activeGeofences[id] ?: mapOf("identifier" to id) // fallback tolerant even w/o restore
143
+ val evt = base.toMutableMap().apply {
144
+ put("identifier", id)
145
+ put("action", action)
146
+ lastLocationProvider?.invoke()?.let { put("location", it) }
147
+ }
148
+ onGeofence?.invoke(evt)
149
+ }
150
+ }
151
+
152
+ private fun notifyChange() {
153
+ onGeofencesChange?.invoke(
154
+ mapOf("on" to activeGeofences.values.toList(), "off" to emptyList<Any>())
155
+ )
156
+ }
157
+
158
+ // Persistence for geofence durability (regions survive app restart).
159
+ private fun persistGeofences() {
160
+ store?.saveGeofences(activeGeofences.values.toList())
161
+ }
162
+
163
+ private fun restorePersistedGeofences() {
164
+ val saved = store?.loadGeofences() ?: emptyList()
165
+ if (saved.isNotEmpty()) {
166
+ saved.forEach {
167
+ val id = it["identifier"] as? String ?: return@forEach
168
+ activeGeofences[id] = it
169
+ }
170
+ }
171
+ }
172
+
173
+ private fun createPendingIntent(): android.app.PendingIntent {
174
+ val intent = android.content.Intent(context, GeofenceTransitionReceiver::class.java).apply {
175
+ action = GeofenceTransitionReceiver.ACTION
176
+ }
177
+ val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
178
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
179
+ } else {
180
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT
181
+ }
182
+ return android.app.PendingIntent.getBroadcast(context, 0, intent, flags)
183
+ }
184
+ }
@@ -0,0 +1,34 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import com.google.android.gms.location.Geofence
7
+ import com.google.android.gms.location.GeofencingEvent
8
+
9
+ /**
10
+ * BroadcastReceiver to receive geofence transition events from GeofencingClient.
11
+ * Forwards to the module via broadcast with extras (module listens).
12
+ */
13
+ class GeofenceTransitionReceiver : BroadcastReceiver() {
14
+ companion object {
15
+ const val ACTION = "expo.modules.thermsdevicetracker.GEOFENCE_TRANSITION"
16
+ const val EXTRA_TRANSITION = "transition"
17
+ const val EXTRA_IDS = "geofence_ids"
18
+ }
19
+
20
+ override fun onReceive(context: Context, intent: Intent) {
21
+ val event = GeofencingEvent.fromIntent(intent) ?: return
22
+ if (event.hasError()) return
23
+
24
+ val transition = event.geofenceTransition
25
+ val triggeringGeofences = event.triggeringGeofences ?: emptyList()
26
+ val ids = ArrayList(triggeringGeofences.map { it.requestId })
27
+
28
+ val outIntent = Intent(ACTION).apply {
29
+ putExtra(EXTRA_TRANSITION, transition)
30
+ putStringArrayListExtra(EXTRA_IDS, ids)
31
+ }
32
+ context.sendBroadcast(outIntent)
33
+ }
34
+ }
@@ -0,0 +1,84 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.content.Context
4
+ import android.os.Looper
5
+ import com.google.android.gms.location.*
6
+
7
+ /**
8
+ * Focused provider for location concerns (fused client configuration + fg updates).
9
+ *
10
+ * Mirrors iOS LocationProvider.swift + the style of other Android providers
11
+ * (GeofenceProvider, SyncProvider, ScheduleProvider).
12
+ *
13
+ * Responsibilities (fg path):
14
+ * - Configure priority / interval / distanceFilter.
15
+ * - Start / stop location updates using FusedLocationProviderClient.
16
+ *
17
+ * Bg path remains coordinated via ThermsLocationService + broadcast (module level)
18
+ * for now; this provider focuses on direct fg + shared config seam.
19
+ * (Full Android bg location provider usage / unification is future work.)
20
+ *
21
+ * Seam: module wires `onNewLocation` callback (or consumes via start).
22
+ * No direct sendEvent here; module owns event + state + sync trigger (keeps provider thin).
23
+ *
24
+ * Test seam precedent: callers can inject a mock FusedLocationProviderClient.
25
+ */
26
+ class LocationProvider(
27
+ private val context: Context,
28
+ private val fusedClient: FusedLocationProviderClient? = null
29
+ ) {
30
+ private val client: FusedLocationProviderClient by lazy {
31
+ fusedClient ?: LocationServices.getFusedLocationProviderClient(context)
32
+ }
33
+
34
+ var onNewLocation: ((android.location.Location) -> Unit)? = null
35
+ var onError: ((String, String) -> Unit)? = null
36
+
37
+ private var currentCallback: LocationCallback? = null
38
+ private var currentRequest: LocationRequest? = null
39
+
40
+ fun configure(
41
+ priority: Int = Priority.PRIORITY_BALANCED_POWER_ACCURACY,
42
+ intervalMs: Long = 5000L,
43
+ minDistanceMeters: Float = 0f
44
+ ) {
45
+ currentRequest = LocationRequest.Builder(priority, intervalMs)
46
+ .setMinUpdateDistanceMeters(minDistanceMeters)
47
+ .build()
48
+ }
49
+
50
+ fun startUpdatingLocation() {
51
+ val request = currentRequest ?: LocationRequest.Builder(
52
+ Priority.PRIORITY_BALANCED_POWER_ACCURACY, 5000L
53
+ ).build()
54
+
55
+ currentCallback = object : LocationCallback() {
56
+ override fun onLocationResult(result: LocationResult) {
57
+ result.lastLocation?.let { loc ->
58
+ onNewLocation?.invoke(loc)
59
+ }
60
+ }
61
+ }
62
+
63
+ try {
64
+ client.requestLocationUpdates(
65
+ request,
66
+ currentCallback!!,
67
+ Looper.getMainLooper()
68
+ )
69
+ } catch (e: SecurityException) {
70
+ onError?.invoke("permission_denied", e.message ?: "Location permission required")
71
+ }
72
+ }
73
+
74
+ fun stopUpdatingLocation() {
75
+ currentCallback?.let {
76
+ try {
77
+ client.removeLocationUpdates(it)
78
+ } catch (_: Exception) {}
79
+ }
80
+ currentCallback = null
81
+ }
82
+
83
+ fun getClient(): FusedLocationProviderClient = client
84
+ }
@@ -0,0 +1,150 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+
6
+ /**
7
+ * Android equivalent of iOS LocationStore.
8
+ * Extracted persistence logic (SharedPreferences) to keep main module thin and testable.
9
+ * Supports stable "id" for destroyLocation(id).
10
+ *
11
+ * Follows patterns: simple focused class, like LocationProvider / GeofenceProvider direction.
12
+ * Includes full geofence storage (save/load/clear + roundtrip) wired by GeofenceProvider for durability.
13
+ */
14
+ class LocationStore(context: Context, prefs: SharedPreferences? = null) : SyncDataSource {
15
+ private val prefs: SharedPreferences =
16
+ prefs ?: context.getSharedPreferences("therms_persist", Context.MODE_PRIVATE)
17
+ private val persistedLocationsKey = "persisted_locations"
18
+ private val persistedGeofencesKey = "persisted_geofences"
19
+
20
+ fun clear() {
21
+ prefs.edit().remove(persistedLocationsKey).apply()
22
+ }
23
+
24
+ fun insert(loc: Map<String, Any?>) {
25
+ var record = loc.toMutableMap()
26
+ if (record["id"] == null) {
27
+ record["id"] = java.util.UUID.randomUUID().toString()
28
+ }
29
+ val current = load()
30
+ val updated = (current + record).takeLast(500)
31
+ save(updated)
32
+ }
33
+
34
+ fun remove(byId: String?) {
35
+ if (byId == null) return
36
+ val current = load().toMutableList()
37
+ current.removeAll { (it["id"] as? String) == byId }
38
+ save(current)
39
+ }
40
+
41
+ fun count(): Int = load().size
42
+
43
+ override fun getAll(): List<Map<String, Any?>> = load()
44
+
45
+ // Internal load/save using the compact persisted format (id,lat,lon,ts || separated)
46
+ // Tolerant of older records.
47
+ private fun load(): List<Map<String, Any?>> {
48
+ val raw = prefs.getString(persistedLocationsKey, "") ?: ""
49
+ if (raw.isEmpty()) return emptyList()
50
+ return raw.split("||").mapNotNull { seg ->
51
+ val p = seg.split(",")
52
+ when {
53
+ p.size >= 4 -> mapOf(
54
+ "id" to p[0],
55
+ "latitude" to (p[1].toDoubleOrNull() ?: 0.0),
56
+ "longitude" to (p[2].toDoubleOrNull() ?: 0.0),
57
+ "timestamp" to (p[3].toDoubleOrNull() ?: 0.0)
58
+ )
59
+ p.size >= 3 -> mapOf(
60
+ "id" to java.util.UUID.randomUUID().toString(),
61
+ "latitude" to (p[0].toDoubleOrNull() ?: 0.0),
62
+ "longitude" to (p[1].toDoubleOrNull() ?: 0.0),
63
+ "timestamp" to (p[2].toDoubleOrNull() ?: 0.0)
64
+ )
65
+ else -> null
66
+ }
67
+ }
68
+ }
69
+
70
+ private fun save(list: List<Map<String, Any?>>) {
71
+ val s = list.joinToString("||") { m ->
72
+ val id = m["id"] ?: java.util.UUID.randomUUID().toString()
73
+ "$id,${m["latitude"] ?: 0},${m["longitude"] ?: 0},${m["timestamp"] ?: 0}"
74
+ }
75
+ prefs.edit().putString(persistedLocationsKey, s).apply()
76
+ }
77
+
78
+ // Geofence storage for durability / parity with iOS (core fields roundtrip).
79
+ // Serialized as delimited (no JSON dep). Optional 7th field for extras (k=v;k2=v2 with minimal escaping for ;/= using %3B/%3D, tolerates legacy 6-field).
80
+ // Extras roundtripped when present to match iOS dict preservation.
81
+ // Wired via GeofenceProvider (persist on mutations, restore on startMonitoring).
82
+ fun loadGeofences(): List<Map<String, Any?>> {
83
+ val raw = prefs.getString(persistedGeofencesKey, "") ?: ""
84
+ if (raw.isEmpty()) return emptyList()
85
+ return raw.split("||").mapNotNull { seg ->
86
+ val p = seg.split("|")
87
+ if (p.size >= 6) {
88
+ val base = mapOf<String, Any?>(
89
+ "identifier" to p[0],
90
+ "latitude" to (p[1].toDoubleOrNull() ?: 0.0),
91
+ "longitude" to (p[2].toDoubleOrNull() ?: 0.0),
92
+ "radius" to (p[3].toDoubleOrNull() ?: 100.0),
93
+ "notifyOnEntry" to (p[4] == "1"),
94
+ "notifyOnExit" to (p[5] == "1")
95
+ )
96
+ if (p.size >= 7 && p[6].isNotBlank()) {
97
+ base + ("extras" to parseExtras(p[6]))
98
+ } else base
99
+ } else null
100
+ }
101
+ }
102
+
103
+ fun saveGeofences(geofences: List<Map<String, Any?>>) {
104
+ val s = geofences.joinToString("||") { m ->
105
+ val id = m["identifier"] ?: ""
106
+ val lat = (m["latitude"] as? Number)?.toDouble() ?: 0.0
107
+ val lon = (m["longitude"] as? Number)?.toDouble() ?: 0.0
108
+ val rad = (m["radius"] as? Number)?.toDouble() ?: 100.0
109
+ val entry = if ((m["notifyOnEntry"] as? Boolean) ?: true) "1" else "0"
110
+ val exit = if ((m["notifyOnExit"] as? Boolean) ?: true) "1" else "0"
111
+ val ex = serializeExtras(m["extras"] as? Map<*, *>)
112
+ "$id|$lat|$lon|$rad|$entry|$exit|$ex"
113
+ }
114
+ prefs.edit().putString(persistedGeofencesKey, s).apply()
115
+ }
116
+
117
+ private fun serializeExtras(ex: Map<*, *>?): String {
118
+ if (ex == null || ex.isEmpty()) return ""
119
+ return ex.entries.joinToString(";") { e ->
120
+ val k = escape(e.key?.toString() ?: "")
121
+ val v = escape(e.value?.toString() ?: "")
122
+ "$k=$v"
123
+ }
124
+ }
125
+
126
+ private fun parseExtras(s: String): Map<String, Any?> {
127
+ if (s.isBlank()) return emptyMap()
128
+ return s.split(";").mapNotNull { pair ->
129
+ val kv = pair.split("=", limit = 2)
130
+ if (kv.size == 2) {
131
+ val k = unescape(kv[0])
132
+ val vs = unescape(kv[1])
133
+ val v: Any? = when {
134
+ vs.equals("true", ignoreCase = true) -> true
135
+ vs.equals("false", ignoreCase = true) -> false
136
+ vs.toDoubleOrNull() != null -> vs.toDoubleOrNull()
137
+ else -> vs
138
+ }
139
+ k to v
140
+ } else null
141
+ }.toMap()
142
+ }
143
+
144
+ private fun escape(s: String): String = s.replace(";", "%3B").replace("=", "%3D")
145
+ private fun unescape(s: String): String = s.replace("%3B", ";").replace("%3D", "=")
146
+
147
+ fun clearGeofences() {
148
+ prefs.edit().remove(persistedGeofencesKey).apply()
149
+ }
150
+ }
@@ -0,0 +1,55 @@
1
+ package expo.modules.thermsdevicetracker
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+
7
+ /**
8
+ * Focused provider for schedule/heartbeat concerns (mirrors iOS ScheduleManager).
9
+ *
10
+ * Extracted to keep the main ThermsDeviceTrackerModule thin and improve cross-platform
11
+ * parity with iOS (which honors `schedule.interval` via ScheduleManager).
12
+ *
13
+ * Responsibilities:
14
+ * - Start/stop a configurable heartbeat.
15
+ * - Emit via onSchedule callback (consistent seam with onSync, onGeofence, etc.).
16
+ * - Accept simple config (interval in seconds).
17
+ *
18
+ * Current implementation is still a demo timer (process must be alive). Real background
19
+ * schedules would use WorkManager + BGTaskScheduler equivalents (documented limitation).
20
+ *
21
+ * Seam: owner sets `onSchedule` after construction (or pass via ctor in future).
22
+ */
23
+ class ScheduleProvider(
24
+ private val context: Context
25
+ ) {
26
+ var onSchedule: ((Map<String, Any?>) -> Unit)? = null
27
+
28
+ private var scheduleHandler: Handler? = null
29
+ private var scheduleRunnable: Runnable? = null
30
+
31
+ fun start(config: Map<String, Any?>? = null) {
32
+ stop()
33
+ onSchedule?.invoke(mapOf("state" to "started"))
34
+
35
+ // Honor interval from config when provided (parity with iOS ScheduleManager).
36
+ // Default 60s to match prior inline behavior.
37
+ val intervalMs = ((config?.get("interval") as? Number)?.toLong() ?: 60L) * 1000L
38
+
39
+ scheduleHandler = Handler(Looper.getMainLooper())
40
+ scheduleRunnable = object : Runnable {
41
+ override fun run() {
42
+ onSchedule?.invoke(mapOf("state" to "heartbeat"))
43
+ scheduleHandler?.postDelayed(this, intervalMs)
44
+ }
45
+ }
46
+ scheduleHandler?.postDelayed(scheduleRunnable!!, intervalMs)
47
+ }
48
+
49
+ fun stop() {
50
+ scheduleRunnable?.let { scheduleHandler?.removeCallbacks(it) }
51
+ scheduleHandler = null
52
+ scheduleRunnable = null
53
+ onSchedule?.invoke(mapOf("state" to "stopped"))
54
+ }
55
+ }