spotny-sdk 1.0.26 → 1.0.27
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.
|
@@ -22,8 +22,6 @@ private const val TAG = "SpotnySDK"
|
|
|
22
22
|
// ── Campaign data ─────────────────────────────────────────────────────────────
|
|
23
23
|
private data class CampaignData(
|
|
24
24
|
val campaignId: Int?,
|
|
25
|
-
|
|
26
|
-
val sessionId: String?,
|
|
27
25
|
val inQueue: Boolean,
|
|
28
26
|
val major: Int,
|
|
29
27
|
val minor: Int
|
|
@@ -72,7 +70,16 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
72
70
|
private val eventInProgress = mutableMapOf<String, Boolean>()
|
|
73
71
|
private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
|
|
74
72
|
private val campaignFetched = mutableMapOf<String, Boolean>()
|
|
75
|
-
private val campaignFetchCooldown = mutableMapOf<String, Long>() //
|
|
73
|
+
private val campaignFetchCooldown = mutableMapOf<String, Long>() // epoch-ms backoff
|
|
74
|
+
// 410 from /track means backend session expired — stop heartbeats until next NEARBY
|
|
75
|
+
private val impressionDisabled = mutableMapOf<String, Boolean>()
|
|
76
|
+
// Last time each beacon was seen in ranging — used to detect slow drift exits
|
|
77
|
+
private val beaconLastSeen = mutableMapOf<String, Long>()
|
|
78
|
+
|
|
79
|
+
// ── JS event deduplication ────────────────────────────────────────────────
|
|
80
|
+
private var lastRangedSignature = ""
|
|
81
|
+
private var lastRangedEmit = 0L
|
|
82
|
+
private var lastSessionHeartbeat = 0L
|
|
76
83
|
|
|
77
84
|
// ── Module registration ───────────────────────────────────────────────────
|
|
78
85
|
|
|
@@ -150,7 +157,7 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
150
157
|
?.let { maxDetectionDistance = it; Log.d(TAG, "maxDetectionDistance = $it m") }
|
|
151
158
|
config?.getString("apiKey")?.let { apiKey = it; Log.d(TAG, "apiKey = $it") }
|
|
152
159
|
config?.getDouble("distanceCorrectionFactor")
|
|
153
|
-
.takeIf { config?.hasKey("distanceCorrectionFactor") == true && it > 0 }
|
|
160
|
+
.takeIf { config?.hasKey("distanceCorrectionFactor") == true && (it ?: 0.0) > 0 }
|
|
154
161
|
?.let { distanceCorrectionFactor = it; Log.d(TAG, "distanceCorrectionFactor = $it") }
|
|
155
162
|
val token = config?.getString("token")?.takeIf { it.isNotBlank() }
|
|
156
163
|
if (token == null) {
|
|
@@ -171,13 +178,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
171
178
|
|
|
172
179
|
// Send token + apiKey + identifierId to backend — response returns a JWT used for all subsequent calls
|
|
173
180
|
// sdkToken is null here so no Authorization header is injected on this call
|
|
174
|
-
val verifyPayload = mapOf("
|
|
181
|
+
val verifyPayload = mapOf("api_token" to token, "api_key" to key, "identifier_id" to uid, "device_id" to getDeviceId())
|
|
175
182
|
post("$apiBasePath/verify", verifyPayload) { status, body ->
|
|
176
183
|
when {
|
|
177
184
|
status in 200..299 -> {
|
|
178
185
|
val verifyJson = try { parseJsonObject(body) } catch (_: Exception) { null }
|
|
179
186
|
val verifyData = verifyJson?.get("data") as? Map<*, *>
|
|
180
|
-
val jwt = (verifyData?.get("
|
|
187
|
+
val jwt = (verifyData?.get("token") as? String)?.takeIf { it.isNotBlank() }
|
|
181
188
|
if (jwt == null) {
|
|
182
189
|
promise.reject("VERIFY_FAILED", "SDK verification response missing JWT")
|
|
183
190
|
return@post
|
|
@@ -262,32 +269,66 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
262
269
|
private val rangeNotifier = RangeNotifier { beacons, region ->
|
|
263
270
|
val now = System.currentTimeMillis()
|
|
264
271
|
|
|
265
|
-
//
|
|
266
|
-
|
|
272
|
+
// Session heartbeat every 60 s to prevent TTL expiry on a live session
|
|
273
|
+
if (now - lastSessionHeartbeat >= 60_000L) {
|
|
274
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE).edit()
|
|
275
|
+
prefs.putLong("sessionTimestamp", now); prefs.apply()
|
|
276
|
+
lastSessionHeartbeat = now
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Build JS event payload for all ranged beacons (with debounce)
|
|
280
|
+
val beaconList = mutableListOf<Map<String, Any>>()
|
|
267
281
|
for (beacon in beacons) {
|
|
268
282
|
val raw = beacon.distance
|
|
269
283
|
val adjusted = raw * distanceCorrectionFactor
|
|
270
284
|
if (adjusted <= 0 || adjusted > maxDetectionDistance) continue
|
|
285
|
+
beaconList.add(mapOf(
|
|
286
|
+
"uuid" to (beacon.id1?.toString() ?: BEACON_UUID),
|
|
287
|
+
"major" to (beacon.id2?.toInt() ?: 0),
|
|
288
|
+
"minor" to (beacon.id3?.toInt() ?: 0),
|
|
289
|
+
"distance" to adjusted,
|
|
290
|
+
"rssi" to beacon.rssi,
|
|
291
|
+
"proximity" to proximityLabel(adjusted)
|
|
292
|
+
))
|
|
293
|
+
}
|
|
271
294
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
if (beaconList.isNotEmpty()) {
|
|
296
|
+
val signature = beaconList
|
|
297
|
+
.map { "${it["major"]}_${it["minor"]}_${it["proximity"]}" }
|
|
298
|
+
.sorted().joinToString(",")
|
|
299
|
+
val stale = now - lastRangedEmit >= debounceInterval
|
|
300
|
+
if (signature != lastRangedSignature || stale) {
|
|
301
|
+
val jsBeacons = WritableNativeArray()
|
|
302
|
+
for (b in beaconList) {
|
|
303
|
+
val bMap = WritableNativeMap().apply {
|
|
304
|
+
putString("uuid", b["uuid"] as String)
|
|
305
|
+
putInt("major", b["major"] as Int)
|
|
306
|
+
putInt("minor", b["minor"] as Int)
|
|
307
|
+
putDouble("distance", b["distance"] as Double)
|
|
308
|
+
putInt("rssi", b["rssi"] as Int)
|
|
309
|
+
putString("proximity", b["proximity"] as String)
|
|
310
|
+
}
|
|
311
|
+
jsBeacons.pushMap(bMap)
|
|
312
|
+
}
|
|
313
|
+
val payload = WritableNativeMap().apply {
|
|
314
|
+
putArray("beacons", jsBeacons)
|
|
315
|
+
putString("region", region.uniqueId)
|
|
316
|
+
}
|
|
317
|
+
sendEvent("onBeaconsRanged", payload)
|
|
318
|
+
lastRangedSignature = signature
|
|
319
|
+
lastRangedEmit = now
|
|
279
320
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (jsBeacons.size() > 0) {
|
|
321
|
+
} else if (lastRangedSignature != "") {
|
|
322
|
+
// All beacons left range — emit empty to let JS clear its state
|
|
283
323
|
val payload = WritableNativeMap().apply {
|
|
284
|
-
putArray("beacons",
|
|
285
|
-
putString("region",
|
|
324
|
+
putArray("beacons", WritableNativeArray())
|
|
325
|
+
putString("region", region.uniqueId)
|
|
286
326
|
}
|
|
287
327
|
sendEvent("onBeaconsRanged", payload)
|
|
328
|
+
lastRangedSignature = ""
|
|
288
329
|
}
|
|
289
330
|
|
|
290
|
-
// Per-beacon logic
|
|
331
|
+
// Per-beacon campaign & proximity logic
|
|
291
332
|
for (beacon in beacons) {
|
|
292
333
|
val major = beacon.id2?.toInt() ?: continue
|
|
293
334
|
val minor = beacon.id3?.toInt() ?: continue
|
|
@@ -296,29 +337,41 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
296
337
|
|
|
297
338
|
if (distance <= 0 || distance > maxDetectionDistance) continue
|
|
298
339
|
|
|
299
|
-
// FIX #9: null lastDist already implies first detection
|
|
300
340
|
val lastDist = lastProximityDistance[key]
|
|
301
341
|
if (lastDist == null || (distance >= 1.0 && abs(distance - lastDist) >= proximityDistanceThreshold)) {
|
|
302
342
|
sendProximity("NEARBY", key, distance)
|
|
303
343
|
lastProximityDistance[key] = distance
|
|
304
344
|
lastProximityEventSent[key] = now
|
|
305
345
|
}
|
|
306
|
-
|
|
346
|
+
|
|
347
|
+
beaconLastSeen[key] = now
|
|
348
|
+
|
|
307
349
|
// Fetch campaign when distance <= 3m
|
|
308
350
|
if (distance <= 3.0 && campaignFetched[key] != true) {
|
|
309
351
|
fetchCampaign(key, major, minor)
|
|
310
352
|
}
|
|
311
|
-
|
|
353
|
+
|
|
312
354
|
// Impression heartbeat when user is very close AND campaign exists
|
|
313
355
|
val campaign = activeCampaigns[key]
|
|
314
|
-
if (campaign?.campaignId != null && campaign.inQueue
|
|
315
|
-
|
|
316
|
-
|
|
356
|
+
if (campaign?.campaignId != null && !campaign.inQueue &&
|
|
357
|
+
impressionDisabled[key] != true && distance <= impressionDistance) {
|
|
358
|
+
val lastImp = lastImpressionEventSent[key] ?: 0L
|
|
359
|
+
if (now - lastImp >= impressionEventInterval) {
|
|
317
360
|
sendImpression(key, distance)
|
|
318
361
|
lastImpressionEventSent[key] = now
|
|
319
362
|
}
|
|
320
363
|
}
|
|
321
364
|
}
|
|
365
|
+
|
|
366
|
+
// Slow-drift exit detection — beacon disappeared without a region exit event
|
|
367
|
+
val beaconExitTimeout = 5_000L
|
|
368
|
+
for (key in lastProximityEventSent.keys.toList()) {
|
|
369
|
+
val lastSeen = beaconLastSeen[key]
|
|
370
|
+
if (lastSeen == null) { beaconLastSeen[key] = now; continue }
|
|
371
|
+
if (now - lastSeen >= beaconExitTimeout) {
|
|
372
|
+
cleanupBeacon(key)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
322
375
|
}
|
|
323
376
|
|
|
324
377
|
// ── Monitor notifier ──────────────────────────────────────────────────────
|
|
@@ -397,24 +450,30 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
397
450
|
// ── State cleanup ─────────────────────────────────────────────────────────
|
|
398
451
|
|
|
399
452
|
private fun cleanupBeacon(key: String) {
|
|
400
|
-
//
|
|
401
|
-
|
|
453
|
+
// Only send exit if NEARBY was previously emitted for this beacon
|
|
454
|
+
val exitDistance = lastProximityDistance[key] ?: 0.01
|
|
455
|
+
if (lastProximityEventSent.containsKey(key)) sendProximity("PROXIMITY_EXIT", key, exitDistance)
|
|
402
456
|
activeCampaigns.remove(key)
|
|
403
457
|
lastProximityEventSent.remove(key)
|
|
404
458
|
lastProximityDistance.remove(key)
|
|
405
459
|
lastImpressionEventSent.remove(key)
|
|
406
|
-
// FIX #8: unified eventInProgress dict with suffixed keys
|
|
407
460
|
eventInProgress.remove("$key:proximity")
|
|
408
461
|
eventInProgress.remove("$key:impression")
|
|
409
462
|
campaignFetchInProgress.remove(key)
|
|
410
463
|
campaignFetched.remove(key)
|
|
411
464
|
campaignFetchCooldown.remove(key)
|
|
465
|
+
impressionDisabled.remove(key)
|
|
466
|
+
beaconLastSeen.remove(key)
|
|
412
467
|
Log.d(TAG, "Cleaned up $key")
|
|
413
468
|
}
|
|
414
469
|
|
|
415
470
|
private fun cleanupAllState() {
|
|
416
|
-
// FIX #3: use lastProximityEventSent — covers beacons even without campaign data
|
|
417
471
|
val keys = lastProximityEventSent.keys.toList()
|
|
472
|
+
if (keys.isEmpty()) {
|
|
473
|
+
// Killed-app wake: in-memory state lost — tell backend to close open sessions
|
|
474
|
+
sendResetEventStates()
|
|
475
|
+
return
|
|
476
|
+
}
|
|
418
477
|
keys.forEachIndexed { index, key ->
|
|
419
478
|
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
420
479
|
cleanupBeacon(key)
|
|
@@ -422,6 +481,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
422
481
|
}
|
|
423
482
|
}
|
|
424
483
|
|
|
484
|
+
private fun sendResetEventStates() {
|
|
485
|
+
if (sdkToken == null) return
|
|
486
|
+
post("$apiBasePath/reset-event-states", emptyMap()) { status, _ ->
|
|
487
|
+
Log.d(TAG, "reset-event-states → $status")
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
425
491
|
// ── Backend API ───────────────────────────────────────────────────────────
|
|
426
492
|
|
|
427
493
|
private fun isTokenExpired(): Boolean {
|
|
@@ -452,14 +518,14 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
452
518
|
readTimeout = 10_000
|
|
453
519
|
doOutput = true
|
|
454
520
|
}
|
|
455
|
-
conn.outputStream.use { it.write(buildJsonString(mapOf("
|
|
521
|
+
conn.outputStream.use { it.write(buildJsonString(mapOf("api_token" to credential, "api_key" to key, "identifier_id" to (identifierId ?: ""), "device_id" to getDeviceId())).toByteArray()) }
|
|
456
522
|
val status = conn.responseCode
|
|
457
523
|
val response = try { conn.inputStream.bufferedReader().readText() }
|
|
458
524
|
catch (_: Exception) { conn.errorStream?.bufferedReader()?.readText() ?: "" }
|
|
459
525
|
conn.disconnect()
|
|
460
526
|
if (status in 200..299) {
|
|
461
527
|
val data = (parseJsonObject(response)?.get("data") as? Map<*, *>)
|
|
462
|
-
val jwt = (data?.get("
|
|
528
|
+
val jwt = (data?.get("token") as? String)?.takeIf { it.isNotBlank() }
|
|
463
529
|
if (jwt != null) {
|
|
464
530
|
sdkToken = jwt
|
|
465
531
|
val expiresAt = (data?.get("expires_at") as? Number)?.toLong()
|
|
@@ -556,7 +622,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
556
622
|
return
|
|
557
623
|
}
|
|
558
624
|
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
559
|
-
campaign.sessionId?.let { payload["session_id"] = it }
|
|
560
625
|
} else {
|
|
561
626
|
activeCampaigns[key]?.campaignId?.let { payload["campaign_id"] = it }
|
|
562
627
|
}
|
|
@@ -564,6 +629,10 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
564
629
|
post(endpoint, payload) { status, _ ->
|
|
565
630
|
if (status in 200..299) {
|
|
566
631
|
Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
|
|
632
|
+
logToFile("$eventType beacon $key @ ${"%.2f".format(distance)}m")
|
|
633
|
+
} else if (status == 410 && isImpression) {
|
|
634
|
+
impressionDisabled[key] = true
|
|
635
|
+
Log.w(TAG, "IMPRESSION_HEARTBEAT session expired (410) — stopping heartbeats for $key")
|
|
567
636
|
} else if (status == 429) {
|
|
568
637
|
val penalty = System.currentTimeMillis() + 10_000L
|
|
569
638
|
if (isImpression) lastImpressionEventSent[key] = penalty
|
|
@@ -596,9 +665,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
596
665
|
if (dataObj != null) {
|
|
597
666
|
val campaignObj = dataObj["campaign"] as? Map<*, *>
|
|
598
667
|
val campaignId = (campaignObj?.get("id") as? Number)?.toInt()
|
|
599
|
-
val sessionId = campaignObj?.get("session_id") as? String
|
|
600
668
|
val inQueue = campaignObj?.get("inQueue") as? Boolean ?: false
|
|
601
|
-
activeCampaigns[key] = CampaignData(campaignId,
|
|
669
|
+
activeCampaigns[key] = CampaignData(campaignId, inQueue, major, minor)
|
|
602
670
|
campaignFetched[key] = true
|
|
603
671
|
Log.d(TAG, "Campaign fetched — campaignId=$campaignId inQueue=$inQueue")
|
|
604
672
|
}
|