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,292 @@
|
|
|
1
|
+
package expo.modules.thermsdevicetracker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import androidx.work.*
|
|
6
|
+
import org.json.JSONArray
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import java.net.HttpURLConnection
|
|
9
|
+
import java.net.URL
|
|
10
|
+
import java.util.concurrent.TimeUnit
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Focused provider for opt-in native background HTTP sync (mirrors GeofenceProvider + schedule).
|
|
14
|
+
*
|
|
15
|
+
* Responsibilities:
|
|
16
|
+
* - Accept config from module (url, method, batch/immediate, interval).
|
|
17
|
+
* - Schedule via WorkManager (periodic for batch; one-time for immediate).
|
|
18
|
+
* - Perform native HTTP inside Worker (lightweight, no JS).
|
|
19
|
+
* - Emit via onSync callback var (consistent with iOS SyncManager; set by module after ctor).
|
|
20
|
+
*
|
|
21
|
+
* Naming seam note: iOS uses SyncManager (and *Manager for Schedule/Geofence); Android uses SyncProvider
|
|
22
|
+
* (and *Provider). This mirrors the established Geofence* split. Do not rename to preserve behavior.
|
|
23
|
+
*
|
|
24
|
+
* Result wiring uses broadcast receiver pattern in module (delegates to handleSyncResult).
|
|
25
|
+
* Keeps main module thin; all sync logic here. onSync callback used (no direct sendEvent from provider).
|
|
26
|
+
*
|
|
27
|
+
* This allows true bg sends without full RN/JS runtime. Requires WorkManager (standard in RN/Expo apps).
|
|
28
|
+
* Worker *prefers live* LocationStore.getAll() at execution (for batch freshness); snapshot in inputData
|
|
29
|
+
* is only for compat/fallback. Consumer decides on destroyLocations after success (see onSync success).
|
|
30
|
+
*
|
|
31
|
+
* Constraints: NETWORK_CONNECTED. Periodic (batch) / OneTime (immediate or syncNow).
|
|
32
|
+
*
|
|
33
|
+
* Android note: Periodic batch uses WorkManager; interval is clamped to >=15min minimum.
|
|
34
|
+
* A "warning" onSync state is emitted on start() if clamping occurred.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Protocol for data source seam (testability, mirrors iOS SyncDataSource protocol).
|
|
38
|
+
// Allows injecting mock data sources. Injected store used for snapshot at enqueue (buildWorkInputData);
|
|
39
|
+
// SyncWorker always prefers fresh LocationStore(applicationContext) at execution (live for batch) + snapshot fallback.
|
|
40
|
+
interface SyncDataSource {
|
|
41
|
+
fun getAll(): List<Map<String, Any?>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class SyncProvider(
|
|
45
|
+
private val context: Context,
|
|
46
|
+
private val store: SyncDataSource? = null
|
|
47
|
+
) {
|
|
48
|
+
// Callback supplied by owner (module) -- consistent with iOS pattern (no sendEvent in ctor).
|
|
49
|
+
var onSync: ((Map<String, Any?>) -> Unit)? = null
|
|
50
|
+
|
|
51
|
+
private var config: Map<String, Any?>? = null
|
|
52
|
+
private var enabled = false
|
|
53
|
+
|
|
54
|
+
companion object {
|
|
55
|
+
const val SYNC_RESULT_ACTION = "expo.modules.thermsdevicetracker.SYNC_RESULT"
|
|
56
|
+
const val EXTRA_STATE = "state"
|
|
57
|
+
const val EXTRA_SUCCESS = "success"
|
|
58
|
+
const val EXTRA_STATUS = "status"
|
|
59
|
+
const val EXTRA_COUNT = "count"
|
|
60
|
+
const val EXTRA_MESSAGE = "message"
|
|
61
|
+
|
|
62
|
+
fun serializeItems(items: List<Map<String, Any?>>): String {
|
|
63
|
+
val ja = JSONArray()
|
|
64
|
+
for (item in items) {
|
|
65
|
+
val jo = JSONObject()
|
|
66
|
+
for ((k, v) in item) {
|
|
67
|
+
when (v) {
|
|
68
|
+
null -> jo.put(k, JSONObject.NULL)
|
|
69
|
+
is Number -> jo.put(k, v)
|
|
70
|
+
is Boolean -> jo.put(k, v)
|
|
71
|
+
else -> jo.put(k, v.toString())
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
ja.put(jo)
|
|
75
|
+
}
|
|
76
|
+
return ja.toString()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fun deserializeItems(jsonStr: String?): List<Map<String, Any?>> {
|
|
80
|
+
if (jsonStr.isNullOrEmpty()) return emptyList()
|
|
81
|
+
return try {
|
|
82
|
+
val ja = JSONArray(jsonStr)
|
|
83
|
+
(0 until ja.length()).map { i ->
|
|
84
|
+
val jo = ja.getJSONObject(i)
|
|
85
|
+
val m = mutableMapOf<String, Any?>()
|
|
86
|
+
val it = jo.keys()
|
|
87
|
+
while (it.hasNext()) {
|
|
88
|
+
val k = it.next() as String
|
|
89
|
+
val raw = jo.opt(k)
|
|
90
|
+
m[k] = when {
|
|
91
|
+
raw == JSONObject.NULL || jo.isNull(k) -> null
|
|
92
|
+
raw is Boolean -> raw
|
|
93
|
+
raw is Int -> raw
|
|
94
|
+
raw is Long -> raw
|
|
95
|
+
raw is Double -> raw
|
|
96
|
+
raw is Float -> raw.toDouble()
|
|
97
|
+
else -> raw.toString()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
m
|
|
101
|
+
}
|
|
102
|
+
} catch (_: Exception) { emptyList() }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fun start(cfg: Map<String, Any?>?) {
|
|
107
|
+
stop()
|
|
108
|
+
config = cfg
|
|
109
|
+
enabled = (cfg?.get("enabled") as? Boolean) ?: false
|
|
110
|
+
if (!enabled || store == null) {
|
|
111
|
+
onSync?.invoke(mapOf("state" to "disabled"))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
val url = cfg?.get("url") as? String ?: return
|
|
115
|
+
onSync?.invoke(mapOf("state" to "started", "url" to url))
|
|
116
|
+
|
|
117
|
+
val batch = (cfg?.get("batch") as? Boolean) ?: true
|
|
118
|
+
var intervalSec = ((cfg?.get("interval") as? Number)?.toLong() ?: 60L)
|
|
119
|
+
if (batch) {
|
|
120
|
+
val MIN_PERIODIC_SEC = 15 * 60L // Android WorkManager minimum for PeriodicWorkRequest
|
|
121
|
+
if (intervalSec < MIN_PERIODIC_SEC) {
|
|
122
|
+
intervalSec = MIN_PERIODIC_SEC
|
|
123
|
+
onSync?.invoke(mapOf("state" to "warning", "message" to "interval clamped to WorkManager min 15min"))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Note: result receiver registration moved to module (broadcast pattern); we only emit via onSync.
|
|
128
|
+
val data = buildWorkInputData()
|
|
129
|
+
|
|
130
|
+
val constraints = Constraints.Builder()
|
|
131
|
+
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
132
|
+
.build()
|
|
133
|
+
|
|
134
|
+
val work: WorkRequest = if (batch) {
|
|
135
|
+
PeriodicWorkRequestBuilder<SyncWorker>(intervalSec, TimeUnit.SECONDS)
|
|
136
|
+
.setConstraints(constraints)
|
|
137
|
+
.setInputData(data)
|
|
138
|
+
.build()
|
|
139
|
+
} else {
|
|
140
|
+
OneTimeWorkRequestBuilder<SyncWorker>()
|
|
141
|
+
.setConstraints(constraints)
|
|
142
|
+
.setInputData(data)
|
|
143
|
+
.build()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (batch) {
|
|
147
|
+
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
|
148
|
+
"therms-sync",
|
|
149
|
+
ExistingPeriodicWorkPolicy.KEEP,
|
|
150
|
+
work as PeriodicWorkRequest
|
|
151
|
+
)
|
|
152
|
+
} else {
|
|
153
|
+
WorkManager.getInstance(context).enqueue(work)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fun stop() {
|
|
158
|
+
WorkManager.getInstance(context).cancelUniqueWork("therms-sync")
|
|
159
|
+
enabled = false
|
|
160
|
+
config = null
|
|
161
|
+
onSync?.invoke(mapOf("state" to "stopped"))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fun syncNow() {
|
|
165
|
+
if (!enabled) return
|
|
166
|
+
// Trigger one-time now. (forces immediate regardless of batch config)
|
|
167
|
+
val url = config?.get("url") as? String ?: return
|
|
168
|
+
// no per-call register; module receiver is registered unconditionally
|
|
169
|
+
|
|
170
|
+
val data = buildWorkInputData()
|
|
171
|
+
val req = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data).build()
|
|
172
|
+
WorkManager.getInstance(context).enqueue(req)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Extracted to dedupe header+items + serialization (used by start + syncNow).
|
|
176
|
+
// NOTE on data model: we pass a snapshot via inputData primarily for immediate cases and compat.
|
|
177
|
+
// Workers prefer *live* store reads at execution time (see SyncWorker) so that batch/periodic
|
|
178
|
+
// picks up fresh data inserted after scheduling. This aligns batch behavior with iOS SyncManager
|
|
179
|
+
// (live store.getAll() inside performSync at timer/syncNow fire time).
|
|
180
|
+
// Snapshot fallback only if live empty (rare; e.g. no persistence or test store without disk).
|
|
181
|
+
// The `batch` flag is used only for scheduling choice (Periodic vs OneTime) in start(); it is
|
|
182
|
+
// deliberately not passed into Data (worker never read it; payload uses maxBatchSize + live getAll()).
|
|
183
|
+
private fun buildWorkInputData(): Data {
|
|
184
|
+
val url = config?.get("url") as? String ?: ""
|
|
185
|
+
val method = (config?.get("method") as? String) ?: "POST"
|
|
186
|
+
val headers = (config?.get("headers") as? Map<*, *>) ?: emptyMap<String, Any?>()
|
|
187
|
+
val headersJson = JSONObject(headers.mapKeys { it.key.toString() }.mapValues { (it.value ?: "").toString() }).toString()
|
|
188
|
+
val maxBatchSize = (config?.get("maxBatchSize") as? Number)?.toInt() ?: 0
|
|
189
|
+
|
|
190
|
+
// Snapshot at enqueue time (captures injected store at call). Worker will prefer live anyway for batch.
|
|
191
|
+
val snapshot = store?.getAll() ?: emptyList()
|
|
192
|
+
val itemsJson = serializeItems(snapshot)
|
|
193
|
+
|
|
194
|
+
return Data.Builder()
|
|
195
|
+
.putString("url", url)
|
|
196
|
+
.putString("method", method)
|
|
197
|
+
.putString("headersJson", headersJson)
|
|
198
|
+
.putInt("maxBatchSize", maxBatchSize)
|
|
199
|
+
.putString("itemsJson", itemsJson)
|
|
200
|
+
.build()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle result from worker broadcast (called by module's registered receiver).
|
|
204
|
+
// All payload shaping + emission stays in provider (logic not leaked to thin module).
|
|
205
|
+
fun handleSyncResult(intent: Intent?) {
|
|
206
|
+
if (intent?.action != SYNC_RESULT_ACTION) return
|
|
207
|
+
val payload = mutableMapOf<String, Any?>()
|
|
208
|
+
intent.getStringExtra(EXTRA_STATE)?.let { payload["state"] = it }
|
|
209
|
+
payload["success"] = intent.getBooleanExtra(EXTRA_SUCCESS, true)
|
|
210
|
+
if (intent.hasExtra(EXTRA_STATUS)) payload["status"] = intent.getIntExtra(EXTRA_STATUS, -1)
|
|
211
|
+
if (intent.hasExtra(EXTRA_COUNT)) payload["count"] = intent.getIntExtra(EXTRA_COUNT, 0)
|
|
212
|
+
intent.getStringExtra(EXTRA_MESSAGE)?.let { payload["message"] = it }
|
|
213
|
+
onSync?.invoke(payload)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Worker does the actual native HTTP.
|
|
217
|
+
// Data fetch: *always prefer live store* at execution (fresh for batch periodic and immediate).
|
|
218
|
+
// This makes batch use current persisted data at WorkManager run time (aligns with iOS live model in performSync).
|
|
219
|
+
// Passed itemsJson from buildWorkInputData is only fallback (e.g. if store empty at exec or no disk).
|
|
220
|
+
// Keeps seam: worker inside provider; module never sees raw items.
|
|
221
|
+
class SyncWorker(appContext: Context, params: WorkerParameters) : Worker(appContext, params) {
|
|
222
|
+
override fun doWork(): Result {
|
|
223
|
+
val urlStr = inputData.getString("url") ?: return Result.failure()
|
|
224
|
+
val method = inputData.getString("method") ?: "POST"
|
|
225
|
+
val headersJson = inputData.getString("headersJson") ?: "{}"
|
|
226
|
+
val maxBatchSize = inputData.getInt("maxBatchSize", 0)
|
|
227
|
+
val itemsJson = inputData.getString("itemsJson")
|
|
228
|
+
|
|
229
|
+
// Prefer live store reads at *execution time* for fresh batch data (consistent across periodic/immediate).
|
|
230
|
+
// This aligns exactly with iOS SyncManager.performSync which always does store.getAll() at fire time.
|
|
231
|
+
// Snapshot (itemsJson) is only fallback/compat for immediate cases or when no disk store present.
|
|
232
|
+
// Fallback to deserialized snapshot only if live yields nothing.
|
|
233
|
+
// IMPORTANT: consumer decides whether to call destroyLocations/destroyLocation after receiving onSync success.
|
|
234
|
+
// There is intentionally NO auto-drain here (see onSync success emission and public destroy* APIs).
|
|
235
|
+
val liveItems = LocationStore(applicationContext).getAll()
|
|
236
|
+
val items = if (liveItems.isNotEmpty()) liveItems else deserializeItems(itemsJson)
|
|
237
|
+
if (items.isEmpty()) {
|
|
238
|
+
sendSyncResult("noop", success = true, count = 0)
|
|
239
|
+
return Result.success()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
val payloadList = if (maxBatchSize > 0) items.take(maxBatchSize) else items
|
|
243
|
+
val count = payloadList.size
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
val url = URL(urlStr)
|
|
247
|
+
val conn = (url.openConnection() as HttpURLConnection).apply {
|
|
248
|
+
requestMethod = method.uppercase()
|
|
249
|
+
doOutput = true
|
|
250
|
+
setRequestProperty("Content-Type", "application/json")
|
|
251
|
+
try {
|
|
252
|
+
val hj = JSONObject(headersJson)
|
|
253
|
+
val iter = hj.keys()
|
|
254
|
+
while (iter.hasNext()) {
|
|
255
|
+
val k = iter.next() as String
|
|
256
|
+
setRequestProperty(k, hj.optString(k))
|
|
257
|
+
}
|
|
258
|
+
} catch (_: Exception) {}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Use shared serialize (no duplication of map->json logic)
|
|
262
|
+
val bodyJson = serializeItems(payloadList)
|
|
263
|
+
conn.outputStream.use { os ->
|
|
264
|
+
os.write(bodyJson.toByteArray(Charsets.UTF_8))
|
|
265
|
+
os.flush()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
val status = conn.responseCode
|
|
269
|
+
val success = status in 200..299
|
|
270
|
+
try { conn.inputStream.close() } catch (_: Exception) {}
|
|
271
|
+
conn.disconnect()
|
|
272
|
+
|
|
273
|
+
sendSyncResult(if (success) "success" else "error", success, status, count)
|
|
274
|
+
return if (success) Result.success() else Result.failure()
|
|
275
|
+
} catch (e: Exception) {
|
|
276
|
+
sendSyncResult("error", success = false, count = count, message = e.message ?: "network error")
|
|
277
|
+
return Result.failure()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private fun sendSyncResult(state: String, success: Boolean, status: Int? = null, count: Int = 0, message: String? = null) {
|
|
282
|
+
val intent = Intent(SYNC_RESULT_ACTION).apply {
|
|
283
|
+
putExtra(EXTRA_STATE, state)
|
|
284
|
+
putExtra(EXTRA_SUCCESS, success)
|
|
285
|
+
status?.let { putExtra(EXTRA_STATUS, it) }
|
|
286
|
+
putExtra(EXTRA_COUNT, count)
|
|
287
|
+
message?.let { putExtra(EXTRA_MESSAGE, it) }
|
|
288
|
+
}
|
|
289
|
+
applicationContext.sendBroadcast(intent)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|