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