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>() // FIX #5: epoch-ms backoff
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("token" to token, "api_key" to key, "user_id" to uid)
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("jwt") as? String)?.takeIf { it.isNotBlank() }
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
- // JS event payload
266
- val jsBeacons = WritableNativeArray()
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
- val b = WritableNativeMap().apply {
273
- putString("uuid", beacon.id1?.toString() ?: BEACON_UUID)
274
- putInt("major", beacon.id2?.toInt() ?: 0)
275
- putInt("minor", beacon.id3?.toInt() ?: 0)
276
- putDouble("distance", adjusted)
277
- putInt("rssi", beacon.rssi)
278
- putString("proximity", proximityLabel(adjusted))
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
- jsBeacons.pushMap(b)
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", jsBeacons)
285
- putString("region", region.uniqueId)
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 == false && distance <= impressionDistance) {
315
- val lastImp = lastImpressionEventSent[key]
316
- if (lastImp == null || now - lastImp >= impressionEventInterval) {
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
- // FIX #6: only send exit if NEARBY was previously emitted for this beacon
401
- if (lastProximityEventSent.containsKey(key)) sendProximity("PROXIMITY_EXIT", key, 0.0)
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("token" to credential, "api_key" to key, "user_id" to (identifierId ?: ""))).toByteArray()) }
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("jwt") as? String)?.takeIf { it.isNotBlank() }
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, sessionId, inQueue, major, minor)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",