spotny-sdk 1.0.26 → 1.0.28

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.
@@ -1,8 +1,12 @@
1
1
  package com.spotnysdk
2
2
 
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
3
5
  import android.content.Context
6
+ import android.os.Build
4
7
  import android.os.RemoteException
5
8
  import android.util.Log
9
+ import androidx.core.app.NotificationCompat
6
10
  import com.facebook.react.bridge.*
7
11
  import com.facebook.react.modules.core.DeviceEventManagerModule
8
12
  import org.altbeacon.beacon.*
@@ -22,8 +26,6 @@ private const val TAG = "SpotnySDK"
22
26
  // ── Campaign data ─────────────────────────────────────────────────────────────
23
27
  private data class CampaignData(
24
28
  val campaignId: Int?,
25
-
26
- val sessionId: String?,
27
29
  val inQueue: Boolean,
28
30
  val major: Int,
29
31
  val minor: Int
@@ -72,7 +74,16 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
72
74
  private val eventInProgress = mutableMapOf<String, Boolean>()
73
75
  private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
74
76
  private val campaignFetched = mutableMapOf<String, Boolean>()
75
- private val campaignFetchCooldown = mutableMapOf<String, Long>() // FIX #5: epoch-ms backoff
77
+ private val campaignFetchCooldown = mutableMapOf<String, Long>() // epoch-ms backoff
78
+ // 410 from /track means backend session expired — stop heartbeats until next NEARBY
79
+ private val impressionDisabled = mutableMapOf<String, Boolean>()
80
+ // Last time each beacon was seen in ranging — used to detect slow drift exits
81
+ private val beaconLastSeen = mutableMapOf<String, Long>()
82
+
83
+ // ── JS event deduplication ────────────────────────────────────────────────
84
+ private var lastRangedSignature = ""
85
+ private var lastRangedEmit = 0L
86
+ private var lastSessionHeartbeat = 0L
76
87
 
77
88
  // ── Module registration ───────────────────────────────────────────────────
78
89
 
@@ -150,7 +161,7 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
150
161
  ?.let { maxDetectionDistance = it; Log.d(TAG, "maxDetectionDistance = $it m") }
151
162
  config?.getString("apiKey")?.let { apiKey = it; Log.d(TAG, "apiKey = $it") }
152
163
  config?.getDouble("distanceCorrectionFactor")
153
- .takeIf { config?.hasKey("distanceCorrectionFactor") == true && it > 0 }
164
+ .takeIf { config?.hasKey("distanceCorrectionFactor") == true && (it ?: 0.0) > 0 }
154
165
  ?.let { distanceCorrectionFactor = it; Log.d(TAG, "distanceCorrectionFactor = $it") }
155
166
  val token = config?.getString("token")?.takeIf { it.isNotBlank() }
156
167
  if (token == null) {
@@ -171,13 +182,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
171
182
 
172
183
  // Send token + apiKey + identifierId to backend — response returns a JWT used for all subsequent calls
173
184
  // 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)
185
+ val verifyPayload = mapOf("api_token" to token, "api_key" to key, "identifier_id" to uid, "device_id" to getDeviceId())
175
186
  post("$apiBasePath/verify", verifyPayload) { status, body ->
176
187
  when {
177
188
  status in 200..299 -> {
178
189
  val verifyJson = try { parseJsonObject(body) } catch (_: Exception) { null }
179
190
  val verifyData = verifyJson?.get("data") as? Map<*, *>
180
- val jwt = (verifyData?.get("jwt") as? String)?.takeIf { it.isNotBlank() }
191
+ val jwt = (verifyData?.get("token") as? String)?.takeIf { it.isNotBlank() }
181
192
  if (jwt == null) {
182
193
  promise.reject("VERIFY_FAILED", "SDK verification response missing JWT")
183
194
  return@post
@@ -262,32 +273,66 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
262
273
  private val rangeNotifier = RangeNotifier { beacons, region ->
263
274
  val now = System.currentTimeMillis()
264
275
 
265
- // JS event payload
266
- val jsBeacons = WritableNativeArray()
276
+ // Session heartbeat every 60 s to prevent TTL expiry on a live session
277
+ if (now - lastSessionHeartbeat >= 60_000L) {
278
+ val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE).edit()
279
+ prefs.putLong("sessionTimestamp", now); prefs.apply()
280
+ lastSessionHeartbeat = now
281
+ }
282
+
283
+ // Build JS event payload for all ranged beacons (with debounce)
284
+ val beaconList = mutableListOf<Map<String, Any>>()
267
285
  for (beacon in beacons) {
268
286
  val raw = beacon.distance
269
287
  val adjusted = raw * distanceCorrectionFactor
270
288
  if (adjusted <= 0 || adjusted > maxDetectionDistance) continue
289
+ beaconList.add(mapOf(
290
+ "uuid" to (beacon.id1?.toString() ?: BEACON_UUID),
291
+ "major" to (beacon.id2?.toInt() ?: 0),
292
+ "minor" to (beacon.id3?.toInt() ?: 0),
293
+ "distance" to adjusted,
294
+ "rssi" to beacon.rssi,
295
+ "proximity" to proximityLabel(adjusted)
296
+ ))
297
+ }
271
298
 
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))
299
+ if (beaconList.isNotEmpty()) {
300
+ val signature = beaconList
301
+ .map { "${it["major"]}_${it["minor"]}_${it["proximity"]}" }
302
+ .sorted().joinToString(",")
303
+ val stale = now - lastRangedEmit >= debounceInterval
304
+ if (signature != lastRangedSignature || stale) {
305
+ val jsBeacons = WritableNativeArray()
306
+ for (b in beaconList) {
307
+ val bMap = WritableNativeMap().apply {
308
+ putString("uuid", b["uuid"] as String)
309
+ putInt("major", b["major"] as Int)
310
+ putInt("minor", b["minor"] as Int)
311
+ putDouble("distance", b["distance"] as Double)
312
+ putInt("rssi", b["rssi"] as Int)
313
+ putString("proximity", b["proximity"] as String)
314
+ }
315
+ jsBeacons.pushMap(bMap)
316
+ }
317
+ val payload = WritableNativeMap().apply {
318
+ putArray("beacons", jsBeacons)
319
+ putString("region", region.uniqueId)
320
+ }
321
+ sendEvent("onBeaconsRanged", payload)
322
+ lastRangedSignature = signature
323
+ lastRangedEmit = now
279
324
  }
280
- jsBeacons.pushMap(b)
281
- }
282
- if (jsBeacons.size() > 0) {
325
+ } else if (lastRangedSignature != "") {
326
+ // All beacons left range — emit empty to let JS clear its state
283
327
  val payload = WritableNativeMap().apply {
284
- putArray("beacons", jsBeacons)
285
- putString("region", region.uniqueId)
328
+ putArray("beacons", WritableNativeArray())
329
+ putString("region", region.uniqueId)
286
330
  }
287
331
  sendEvent("onBeaconsRanged", payload)
332
+ lastRangedSignature = ""
288
333
  }
289
334
 
290
- // Per-beacon logic
335
+ // Per-beacon campaign & proximity logic
291
336
  for (beacon in beacons) {
292
337
  val major = beacon.id2?.toInt() ?: continue
293
338
  val minor = beacon.id3?.toInt() ?: continue
@@ -296,29 +341,55 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
296
341
 
297
342
  if (distance <= 0 || distance > maxDetectionDistance) continue
298
343
 
299
- // FIX #9: null lastDist already implies first detection
300
344
  val lastDist = lastProximityDistance[key]
301
345
  if (lastDist == null || (distance >= 1.0 && abs(distance - lastDist) >= proximityDistanceThreshold)) {
346
+ if (lastDist == null) {
347
+ val enterPayload = WritableNativeMap().apply {
348
+ putInt("major", major)
349
+ putInt("minor", minor)
350
+ putString("beaconId", key)
351
+ putDouble("distance", distance)
352
+ putString("region", "SpotnySDK_GeneralRegion")
353
+ }
354
+ sendEvent("onBeaconRangeEnter", enterPayload)
355
+ sendLocalNotification(
356
+ "📡 Beacon Detected",
357
+ "Major: $major, Minor: $minor — ${"%,.1f".format(distance)}m away"
358
+ )
359
+ }
302
360
  sendProximity("NEARBY", key, distance)
303
361
  lastProximityDistance[key] = distance
304
362
  lastProximityEventSent[key] = now
305
363
  }
306
-
307
- // Fetch campaign when distance <= 3m
308
- if (distance <= 3.0 && campaignFetched[key] != true) {
364
+
365
+ beaconLastSeen[key] = now
366
+
367
+ // Fetch campaign when distance <= 5m
368
+ if (distance <= 5.0 && campaignFetched[key] != true) {
309
369
  fetchCampaign(key, major, minor)
310
370
  }
311
-
371
+
312
372
  // Impression heartbeat when user is very close AND campaign exists
313
373
  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) {
374
+ if (campaign?.campaignId != null && !campaign.inQueue &&
375
+ impressionDisabled[key] != true && distance <= impressionDistance) {
376
+ val lastImp = lastImpressionEventSent[key] ?: 0L
377
+ if (now - lastImp >= impressionEventInterval) {
317
378
  sendImpression(key, distance)
318
379
  lastImpressionEventSent[key] = now
319
380
  }
320
381
  }
321
382
  }
383
+
384
+ // Slow-drift exit detection — beacon disappeared without a region exit event
385
+ val beaconExitTimeout = 5_000L
386
+ for (key in lastProximityEventSent.keys.toList()) {
387
+ val lastSeen = beaconLastSeen[key]
388
+ if (lastSeen == null) { beaconLastSeen[key] = now; continue }
389
+ if (now - lastSeen >= beaconExitTimeout) {
390
+ cleanupBeacon(key)
391
+ }
392
+ }
322
393
  }
323
394
 
324
395
  // ── Monitor notifier ──────────────────────────────────────────────────────
@@ -383,6 +454,24 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
383
454
  .emit(name, payload)
384
455
  }
385
456
 
457
+ private fun sendLocalNotification(title: String, body: String) {
458
+ val channelId = "spotny_sdk_channel"
459
+ val notifManager = reactContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
460
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
461
+ val channel = NotificationChannel(channelId, "Spotny SDK", NotificationManager.IMPORTANCE_DEFAULT)
462
+ notifManager.createNotificationChannel(channel)
463
+ }
464
+ val appIcon = reactContext.applicationInfo.icon.takeIf { it != 0 }
465
+ ?: android.R.drawable.ic_dialog_info
466
+ val notification = NotificationCompat.Builder(reactContext, channelId)
467
+ .setSmallIcon(appIcon)
468
+ .setContentTitle(title)
469
+ .setContentText(body)
470
+ .setAutoCancel(true)
471
+ .build()
472
+ notifManager.notify(System.currentTimeMillis().toInt(), notification)
473
+ }
474
+
386
475
  private fun logFile() = File(reactContext.filesDir, "spotny_beacon_debug.log")
387
476
 
388
477
  private fun logToFile(message: String) {
@@ -397,24 +486,30 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
397
486
  // ── State cleanup ─────────────────────────────────────────────────────────
398
487
 
399
488
  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)
489
+ // Only send exit if NEARBY was previously emitted for this beacon
490
+ val exitDistance = lastProximityDistance[key] ?: 0.01
491
+ if (lastProximityEventSent.containsKey(key)) sendProximity("PROXIMITY_EXIT", key, exitDistance)
402
492
  activeCampaigns.remove(key)
403
493
  lastProximityEventSent.remove(key)
404
494
  lastProximityDistance.remove(key)
405
495
  lastImpressionEventSent.remove(key)
406
- // FIX #8: unified eventInProgress dict with suffixed keys
407
496
  eventInProgress.remove("$key:proximity")
408
497
  eventInProgress.remove("$key:impression")
409
498
  campaignFetchInProgress.remove(key)
410
499
  campaignFetched.remove(key)
411
500
  campaignFetchCooldown.remove(key)
501
+ impressionDisabled.remove(key)
502
+ beaconLastSeen.remove(key)
412
503
  Log.d(TAG, "Cleaned up $key")
413
504
  }
414
505
 
415
506
  private fun cleanupAllState() {
416
- // FIX #3: use lastProximityEventSent — covers beacons even without campaign data
417
507
  val keys = lastProximityEventSent.keys.toList()
508
+ if (keys.isEmpty()) {
509
+ // Killed-app wake: in-memory state lost — tell backend to close open sessions
510
+ sendResetEventStates()
511
+ return
512
+ }
418
513
  keys.forEachIndexed { index, key ->
419
514
  android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
420
515
  cleanupBeacon(key)
@@ -422,6 +517,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
422
517
  }
423
518
  }
424
519
 
520
+ private fun sendResetEventStates() {
521
+ if (sdkToken == null) return
522
+ post("$apiBasePath/reset-event-states", emptyMap()) { status, _ ->
523
+ Log.d(TAG, "reset-event-states → $status")
524
+ }
525
+ }
526
+
425
527
  // ── Backend API ───────────────────────────────────────────────────────────
426
528
 
427
529
  private fun isTokenExpired(): Boolean {
@@ -452,14 +554,14 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
452
554
  readTimeout = 10_000
453
555
  doOutput = true
454
556
  }
455
- conn.outputStream.use { it.write(buildJsonString(mapOf("token" to credential, "api_key" to key, "user_id" to (identifierId ?: ""))).toByteArray()) }
557
+ 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
558
  val status = conn.responseCode
457
559
  val response = try { conn.inputStream.bufferedReader().readText() }
458
560
  catch (_: Exception) { conn.errorStream?.bufferedReader()?.readText() ?: "" }
459
561
  conn.disconnect()
460
562
  if (status in 200..299) {
461
563
  val data = (parseJsonObject(response)?.get("data") as? Map<*, *>)
462
- val jwt = (data?.get("jwt") as? String)?.takeIf { it.isNotBlank() }
564
+ val jwt = (data?.get("token") as? String)?.takeIf { it.isNotBlank() }
463
565
  if (jwt != null) {
464
566
  sdkToken = jwt
465
567
  val expiresAt = (data?.get("expires_at") as? Number)?.toLong()
@@ -556,7 +658,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
556
658
  return
557
659
  }
558
660
  campaign.campaignId?.let { payload["campaign_id"] = it }
559
- campaign.sessionId?.let { payload["session_id"] = it }
560
661
  } else {
561
662
  activeCampaigns[key]?.campaignId?.let { payload["campaign_id"] = it }
562
663
  }
@@ -564,6 +665,10 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
564
665
  post(endpoint, payload) { status, _ ->
565
666
  if (status in 200..299) {
566
667
  Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
668
+ logToFile("$eventType beacon $key @ ${"%.2f".format(distance)}m")
669
+ } else if (status == 410 && isImpression) {
670
+ impressionDisabled[key] = true
671
+ Log.w(TAG, "IMPRESSION_HEARTBEAT session expired (410) — stopping heartbeats for $key")
567
672
  } else if (status == 429) {
568
673
  val penalty = System.currentTimeMillis() + 10_000L
569
674
  if (isImpression) lastImpressionEventSent[key] = penalty
@@ -596,9 +701,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
596
701
  if (dataObj != null) {
597
702
  val campaignObj = dataObj["campaign"] as? Map<*, *>
598
703
  val campaignId = (campaignObj?.get("id") as? Number)?.toInt()
599
- val sessionId = campaignObj?.get("session_id") as? String
600
704
  val inQueue = campaignObj?.get("inQueue") as? Boolean ?: false
601
- activeCampaigns[key] = CampaignData(campaignId, sessionId, inQueue, major, minor)
705
+ activeCampaigns[key] = CampaignData(campaignId, inQueue, major, minor)
602
706
  campaignFetched[key] = true
603
707
  Log.d(TAG, "Campaign fetched — campaignId=$campaignId inQueue=$inQueue")
604
708
  }
@@ -894,6 +894,19 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
894
894
  // FIX #9: nil lastDist already implies first detection — no separate isFirst needed
895
895
  let lastDist = lastProximityDistance[key]
896
896
  if lastDist == nil || (distance >= 1.0 && abs(distance - (lastDist ?? 0)) >= proximityDistanceThreshold) {
897
+ if lastDist == nil {
898
+ eventCallback?("onBeaconRangeEnter", [
899
+ "major": major,
900
+ "minor": minor,
901
+ "beaconId": key,
902
+ "distance": distance,
903
+ "region": "SpotnySDK_GeneralRegion"
904
+ ])
905
+ sendLocalNotification(
906
+ title: "📡 Beacon Detected",
907
+ body: "Major: \(major), Minor: \(minor) — \(String(format: "%.1f", distance))m away"
908
+ )
909
+ }
897
910
  sendProximity(eventType: "NEARBY", key: key, distance: distance)
898
911
  lastProximityDistance[key] = distance
899
912
  lastProximityEventSent[key] = now
@@ -901,8 +914,8 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
901
914
 
902
915
  beaconLastSeen[key] = now
903
916
 
904
- // Fetch campaign when distance <= 3m
905
- if distance <= 3.0 && campaignFetched[key] != true {
917
+ // Fetch campaign when distance <= 5m
918
+ if distance <= 5.0 && campaignFetched[key] != true {
906
919
  fetchCampaign(key: key, major: major, minor: minor)
907
920
  }
908
921
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",