spotny-sdk 1.0.7 → 1.0.9

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/README.md CHANGED
@@ -270,13 +270,13 @@ Returns `Promise<Object>`.
270
270
  1. `initialize()` sends your `token`, `apiKey`, and `identifierId` to the Spotny backend, which issues a session JWT. The JWT is persisted locally and auto-refreshed when it expires.
271
271
  2. `startScanner()` begins monitoring for iBeacons with the Spotny UUID.
272
272
  3. When a beacon is first detected, the SDK immediately sends a `NEARBY` proximity event with the `beacon_id` to log the user's presence.
273
- 4. When the user gets within **3 meters**, the SDK calls `/distribute` with the `beacon_id` to fetch campaign data (`screen_id`, `campaign_id`, `session_id`, `inQueue` flag).
274
- 5. The SDK stores this campaign data and uses it for subsequent impression events.
275
- 6. Additional `NEARBY` events are sent automatically as the user moves closer or further (when distance changes > 0.75m).
276
- 7. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of a beacon with an active campaign (not queued). These events include `screen_id`, `campaign_id`, and `session_id`.
273
+ 4. When the user gets within **3 meters**, the SDK calls `/distribute` with the `beacon_id` to fetch campaign data (`campaign_id`, `session_id`, `inQueue` flag).
274
+ 5. The SDK stores this campaign data locally.
275
+ 6. Additional `NEARBY` events are sent automatically as the user moves closer or further (when distance changes > 0.75m). When a campaign is active, these events **include the `campaign_id`**.
276
+ 7. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of a beacon with an active campaign (not queued). These events include `campaign_id` and `session_id`.
277
277
  8. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
278
278
 
279
- **Two-stage tracking:** Proximity events are lightweight (beacon_id only) and sent immediately. Campaign data is fetched only when needed (at 3m), optimizing network usage while ensuring timely engagement tracking.
279
+ **Simplified tracking:** All events send `beacon_id` + optional `campaign_id` when available. The backend resolves beacon screen mapping, allowing flexible beacon reassignment without SDK updates.
280
280
 
281
281
  All events are tied to the authenticated `identifierId` supplied during `initialize()` — no additional identity calls are required.
282
282
 
@@ -64,14 +64,15 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
64
64
  private var debounceInterval = 5_000L // ms
65
65
 
66
66
  // ── Per-beacon state ──────────────────────────────────────────────────────
67
- private val activeCampaigns = mutableMapOf<String, CampaignData>()
68
- private val lastProximityEventSent = mutableMapOf<String, Long>()
69
- private val lastProximityDistance = mutableMapOf<String, Double>()
70
- private val lastImpressionEventSent = mutableMapOf<String, Long>()
71
- private val proximityEventInProgress = mutableMapOf<String, Boolean>()
72
- private val impressionEventInProgress = mutableMapOf<String, Boolean>()
73
- private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
74
- private val campaignFetched = mutableMapOf<String, Boolean>()
67
+ private val activeCampaigns = mutableMapOf<String, CampaignData>()
68
+ private val lastProximityEventSent = mutableMapOf<String, Long>()
69
+ private val lastProximityDistance = mutableMapOf<String, Double>()
70
+ private val lastImpressionEventSent = mutableMapOf<String, Long>()
71
+ // FIX #8: unified dict — key is "<beaconKey>:proximity" or "<beaconKey>:impression"
72
+ private val eventInProgress = mutableMapOf<String, Boolean>()
73
+ private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
74
+ private val campaignFetched = mutableMapOf<String, Boolean>()
75
+ private val campaignFetchCooldown = mutableMapOf<String, Long>() // FIX #5: epoch-ms backoff
75
76
 
76
77
  // ── Module registration ───────────────────────────────────────────────────
77
78
 
@@ -295,21 +296,12 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
295
296
 
296
297
  if (distance <= 0 || distance > maxDetectionDistance) continue
297
298
 
298
- val isFirst = !lastProximityEventSent.containsKey(key)
299
-
300
- if (isFirst) {
301
- // First detection — send proximity immediately
299
+ // FIX #9: null lastDist already implies first detection
300
+ val lastDist = lastProximityDistance[key]
301
+ if (lastDist == null || (distance >= 1.0 && abs(distance - lastDist) >= proximityDistanceThreshold)) {
302
302
  sendProximity("NEARBY", key, distance)
303
303
  lastProximityDistance[key] = distance
304
304
  lastProximityEventSent[key] = now
305
- } else if (distance >= 1.0) {
306
- val lastDist = lastProximityDistance[key] ?: 0.0
307
- if (abs(distance - lastDist) >= proximityDistanceThreshold) {
308
- // Distance changed significantly — send proximity update
309
- sendProximity("NEARBY", key, distance)
310
- lastProximityDistance[key] = distance
311
- lastProximityEventSent[key] = now
312
- }
313
305
  }
314
306
 
315
307
  // Fetch campaign when distance <= 3m
@@ -349,9 +341,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
349
341
  putString("event", "exit")
350
342
  }
351
343
  sendEvent("onBeaconRegionEvent", payload)
352
- for (key in activeCampaigns.keys.toList()) {
353
- cleanupBeacon(key)
354
- }
344
+ // FIX #3: delegate to cleanupAllState which now uses lastProximityEventSent
345
+ cleanupAllState()
355
346
  try { beaconManager.stopRangingBeaconsInRegion(region) } catch (_: RemoteException) {}
356
347
  }
357
348
 
@@ -406,21 +397,24 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
406
397
  // ── State cleanup ─────────────────────────────────────────────────────────
407
398
 
408
399
  private fun cleanupBeacon(key: String) {
409
- sendProximity("PROXIMITY_EXIT", key, 0.0)
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)
410
402
  activeCampaigns.remove(key)
411
403
  lastProximityEventSent.remove(key)
412
404
  lastProximityDistance.remove(key)
413
405
  lastImpressionEventSent.remove(key)
414
- proximityEventInProgress.remove(key)
415
- impressionEventInProgress.remove(key)
406
+ // FIX #8: unified eventInProgress dict with suffixed keys
407
+ eventInProgress.remove("$key:proximity")
408
+ eventInProgress.remove("$key:impression")
416
409
  campaignFetchInProgress.remove(key)
417
410
  campaignFetched.remove(key)
418
- Log.d(TAG, "Cleaned up state for beacon $key")
411
+ campaignFetchCooldown.remove(key)
412
+ Log.d(TAG, "Cleaned up $key")
419
413
  }
420
414
 
421
415
  private fun cleanupAllState() {
422
- // Stagger exit events to avoid firing N simultaneous network requests
423
- val keys = activeCampaigns.keys.toList()
416
+ // FIX #3: use lastProximityEventSent covers beacons even without campaign data
417
+ val keys = lastProximityEventSent.keys.toList()
424
418
  keys.forEachIndexed { index, key ->
425
419
  android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
426
420
  cleanupBeacon(key)
@@ -543,14 +537,12 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
543
537
  distance: Double,
544
538
  endpoint: String
545
539
  ) {
540
+ if (sdkToken == null) return // FIX #4: no-op without JWT
546
541
  val isImpression = eventType == "IMPRESSION_HEARTBEAT"
547
- if (isImpression && impressionEventInProgress[key] == true) return
548
- if (!isImpression && proximityEventInProgress[key] == true) return
549
-
550
- if (isImpression) impressionEventInProgress[key] = true
551
- else proximityEventInProgress[key] = true
542
+ val progressKey = "$key:${if (isImpression) "impression" else "proximity"}" // FIX #8
543
+ if (eventInProgress[progressKey] == true) return
544
+ eventInProgress[progressKey] = true
552
545
 
553
- // Build payload — proximity events send beacon_id only, impressions include campaign data
554
546
  val payload = mutableMapOf<String, Any?>(
555
547
  "event_type" to eventType,
556
548
  "beacon_id" to key,
@@ -558,18 +550,18 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
558
550
  )
559
551
 
560
552
  if (isImpression) {
561
- // Impressions require campaign to exist
562
553
  val campaign = activeCampaigns[key]
563
554
  if (campaign == null || campaign.campaignId == null || campaign.inQueue) {
564
- impressionEventInProgress[key] = false
555
+ eventInProgress[progressKey] = false
565
556
  return
566
557
  }
567
- payload["screen_id"] = campaign.screenId
568
558
  campaign.campaignId?.let { payload["campaign_id"] = it }
569
559
  campaign.sessionId?.let { payload["session_id"] = it }
560
+ } else {
561
+ activeCampaigns[key]?.campaignId?.let { payload["campaign_id"] = it }
570
562
  }
571
563
 
572
- post(endpoint, payload) { status, body ->
564
+ post(endpoint, payload) { status, _ ->
573
565
  if (status in 200..299) {
574
566
  Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
575
567
  } else if (status == 429) {
@@ -580,57 +572,43 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
580
572
  } else {
581
573
  Log.w(TAG, "$eventType failed — status $status")
582
574
  }
583
- if (isImpression) impressionEventInProgress[key] = false
584
- else proximityEventInProgress[key] = false
575
+ eventInProgress[progressKey] = false
585
576
  }
586
577
  }
587
578
 
588
579
  private fun fetchCampaign(key: String, major: Int, minor: Int) {
580
+ if (sdkToken == null) return // FIX #4: no-op without JWT
589
581
  if (campaignFetchInProgress[key] == true) return
590
582
  if (campaignFetched[key] == true) return
591
-
583
+ // FIX #5: respect 30 s cooldown after a failed fetch
584
+ val cooldown = campaignFetchCooldown[key]
585
+ if (cooldown != null && System.currentTimeMillis() < cooldown) return
586
+
592
587
  campaignFetchInProgress[key] = true
593
-
594
- val payload = mapOf("beacon_id" to key)
595
-
596
- post("$apiBasePath/distribute", payload) { status, body ->
588
+
589
+ post("$apiBasePath/distribute", mapOf("beacon_id" to key)) { status, body ->
597
590
  campaignFetchInProgress[key] = false
598
-
591
+
599
592
  if (status in 200..299) {
600
593
  try {
601
- val json = parseJsonObject(body)
594
+ val json = parseJsonObject(body)
602
595
  val dataObj = json?.get("data") as? Map<*, *>
603
-
604
596
  if (dataObj != null) {
605
- val screenId = (dataObj["screen_id"] as? Number)?.toInt() ?: 0
606
- var campaignId: Int? = null
607
- var sessionId: String? = null
608
- var inQueue = false
609
-
610
597
  val campaignObj = dataObj["campaign"] as? Map<*, *>
611
- if (campaignObj != null) {
612
- campaignId = (campaignObj["id"] as? Number)?.toInt()
613
- sessionId = campaignObj["session_id"] as? String
614
- inQueue = campaignObj["inQueue"] as? Boolean ?: false
615
- }
616
-
617
- activeCampaigns[key] = CampaignData(
618
- screenId = screenId,
619
- campaignId = campaignId,
620
- sessionId = sessionId,
621
- inQueue = inQueue,
622
- major = major,
623
- minor = minor
624
- )
598
+ val campaignId = (campaignObj?.get("id") as? Number)?.toInt()
599
+ val sessionId = campaignObj?.get("session_id") as? String
600
+ val inQueue = campaignObj?.get("inQueue") as? Boolean ?: false
601
+ activeCampaigns[key] = CampaignData(campaignId, sessionId, inQueue, major, minor)
625
602
  campaignFetched[key] = true
626
-
627
- Log.d(TAG, "Campaign fetched — screenId=$screenId, campaignId=$campaignId, sessionId=$sessionId, inQueue=$inQueue")
603
+ Log.d(TAG, "Campaign fetched — campaignId=$campaignId inQueue=$inQueue")
628
604
  }
629
605
  } catch (e: Exception) {
630
606
  Log.w(TAG, "Error parsing campaign response: ${e.message}")
607
+ campaignFetchCooldown[key] = System.currentTimeMillis() + 30_000L // FIX #5
631
608
  }
632
609
  } else {
633
610
  Log.w(TAG, "Campaign fetch failed — status $status")
611
+ campaignFetchCooldown[key] = System.currentTimeMillis() + 30_000L // FIX #5
634
612
  }
635
613
  }
636
614
  }
@@ -20,7 +20,6 @@ public typealias SpotnyEventCallback = @convention(block) (_ name: String, _ bod
20
20
  // MARK: - Internal data structures
21
21
 
22
22
  private struct CampaignData {
23
- let screenId: Int
24
23
  let campaignId: Int? // nil when no active campaign
25
24
  let sessionId: String? // set after campaign fetch response
26
25
  let inQueue: Bool // campaign is queued — skip impressions
@@ -87,14 +86,15 @@ public class SpotnyBeaconScanner: NSObject {
87
86
  private var debounceInterval: TimeInterval = 5.0
88
87
 
89
88
  // ── Per-beacon tracking state ──────────────────────────────────────────────
90
- private var activeCampaigns: [String: CampaignData] = [:]
91
- private var lastProximityEventSent: [String: Date] = [:]
92
- private var lastProximityDistance: [String: Double] = [:]
93
- private var lastImpressionEventSent: [String: Date] = [:]
94
- private var proximityEventInProgress: [String: Bool] = [:]
95
- private var impressionEventInProgress: [String: Bool] = [:]
96
- private var campaignFetchInProgress: [String: Bool] = [:]
97
- private var campaignFetched: [String: Bool] = [:]
89
+ private var activeCampaigns: [String: CampaignData] = [:]
90
+ private var lastProximityEventSent: [String: Date] = [:]
91
+ private var lastProximityDistance: [String: Double] = [:]
92
+ private var lastImpressionEventSent: [String: Date] = [:]
93
+ // Unified in-flight guard — keys are "<beaconKey>:proximity" or "<beaconKey>:impression"
94
+ private var eventInProgress: [String: Bool] = [:]
95
+ private var campaignFetchInProgress: [String: Bool] = [:]
96
+ private var campaignFetched: [String: Bool] = [:]
97
+ private var campaignFetchCooldown: [String: Date] = [:] // backoff after failed fetch
98
98
 
99
99
  // MARK: - Init
100
100
 
@@ -128,21 +128,25 @@ public class SpotnyBeaconScanner: NSObject {
128
128
  }
129
129
 
130
130
  private func resumeStoredSession() {
131
- // Resume scanning only if a valid session was previously active
132
- guard let ts = UserDefaults.standard.object(forKey: "SpotnySDK_sessionTimestamp") as? Double else { return }
133
- let age = Date().timeIntervalSince1970 - ts
134
- if age > sessionTTL {
135
- print("⚠️ SpotnySDK: Stale session (\(Int(age / 3600))h old) — discarding")
136
- clearStoredSession()
137
- return
131
+ // Keychain flag survives reinstall UserDefaults alone does not
132
+ guard keychainRead(key: "SpotnySDK_sessionActive") == "1" else { return }
133
+
134
+ let ts = UserDefaults.standard.double(forKey: "SpotnySDK_sessionTimestamp")
135
+ if ts > 0 {
136
+ let age = Date().timeIntervalSince1970 - ts
137
+ if age > sessionTTL {
138
+ print("⚠️ SpotnySDK: Stale session (\(Int(age / 3600))h) — discarding")
139
+ clearStoredSession()
140
+ return
141
+ }
138
142
  }
139
- // Restore persisted JWT + credentials so all API calls work without initialize()
143
+
140
144
  sdkToken = keychainRead(key: "SpotnySDK_jwt")
141
145
  sdkCredential = keychainRead(key: "SpotnySDK_sdkCredential")
142
146
  apiKey = keychainRead(key: "SpotnySDK_apiKey")
143
147
  identifierId = keychainRead(key: "SpotnySDK_identifierId")
144
- if let expiryStr = keychainRead(key: "SpotnySDK_jwtExpiry"), let ts = Double(expiryStr) {
145
- sdkTokenExpiry = Date(timeIntervalSince1970: ts)
148
+ if let expiryStr = keychainRead(key: "SpotnySDK_jwtExpiry"), let expTs = Double(expiryStr) {
149
+ sdkTokenExpiry = Date(timeIntervalSince1970: expTs)
146
150
  }
147
151
  print("🔄 SpotnySDK: Resuming session (device: \(getDeviceId()))")
148
152
  startPersistentScanning()
@@ -150,9 +154,9 @@ public class SpotnyBeaconScanner: NSObject {
150
154
  }
151
155
 
152
156
  private func clearStoredSession() {
153
- // Only clears the scanning session — identity (Keychain) is intentionally kept
154
157
  UserDefaults.standard.removeObject(forKey: "SpotnySDK_sessionTimestamp")
155
158
  UserDefaults.standard.synchronize()
159
+ keychainDelete(key: "SpotnySDK_sessionActive")
156
160
  }
157
161
 
158
162
  // MARK: - ObjC-Exposed Methods (called from SpotnySdk.mm)
@@ -169,6 +173,7 @@ public class SpotnyBeaconScanner: NSObject {
169
173
 
170
174
  UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "SpotnySDK_sessionTimestamp")
171
175
  UserDefaults.standard.synchronize()
176
+ keychainWrite(key: "SpotnySDK_sessionActive", value: "1")
172
177
 
173
178
  let status = locationManager.authorizationStatus
174
179
  if status == .notDetermined {
@@ -189,11 +194,10 @@ public class SpotnyBeaconScanner: NSObject {
189
194
  beaconManager.stopMonitoringForAllRegions()
190
195
  beaconManager.stopRangingBeaconsInAllRegions()
191
196
  cleanupAllProximityState()
192
-
193
197
  clearStoredSession()
194
-
198
+ lastRangedSignature = ""
199
+ lastRangedEmit = .distantPast
195
200
  scanning = false
196
-
197
201
  print("⏹️ SpotnySDK: Stopped scanning")
198
202
  resolve("Scanning stopped")
199
203
  }
@@ -583,20 +587,32 @@ public class SpotnyBeaconScanner: NSObject {
583
587
 
584
588
  // MARK: - Tracking
585
589
 
590
+ /// Keeps the app alive briefly in background after region events.
591
+ private func extendBackgroundTime(duration: TimeInterval) {
592
+ var bgTask: UIBackgroundTaskIdentifier = .invalid
593
+ bgTask = UIApplication.shared.beginBackgroundTask {
594
+ UIApplication.shared.endBackgroundTask(bgTask)
595
+ }
596
+ guard bgTask != .invalid else { return }
597
+ DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
598
+ UIApplication.shared.endBackgroundTask(bgTask)
599
+ }
600
+ }
601
+
586
602
  private func sendTracking(
587
603
  eventType: String,
588
604
  key: String,
589
605
  distance: Double,
590
606
  endpoint: String
591
607
  ) {
592
- let isImpression = eventType == "IMPRESSION_HEARTBEAT"
593
- let inProg = isImpression ? impressionEventInProgress[key] : proximityEventInProgress[key]
594
- guard inProg != true else { return }
608
+ // Skip if JWT not ready (initialize() not yet called or token not restored)
609
+ guard sdkToken != nil else { return }
595
610
 
596
- if isImpression { impressionEventInProgress[key] = true }
597
- else { proximityEventInProgress[key] = true }
611
+ let isImpression = eventType == "IMPRESSION_HEARTBEAT"
612
+ let progressKey = "\(key):\(isImpression ? "impression" : "proximity")"
613
+ guard eventInProgress[progressKey] != true else { return }
614
+ eventInProgress[progressKey] = true
598
615
 
599
- // Build payload — proximity events send beacon_id only, impressions include campaign data
600
616
  var payload: [String: Any] = [
601
617
  "event_type": eventType,
602
618
  "beacon_id": key,
@@ -604,25 +620,25 @@ public class SpotnyBeaconScanner: NSObject {
604
620
  ]
605
621
 
606
622
  if isImpression {
607
- // Impressions require campaign to exist
608
623
  guard let campaign = activeCampaigns[key],
609
624
  let _ = campaign.campaignId,
610
625
  !campaign.inQueue else {
611
- impressionEventInProgress[key] = false
626
+ eventInProgress[progressKey] = false
612
627
  return
613
628
  }
614
- payload["screen_id"] = campaign.screenId
615
629
  if let cid = campaign.campaignId { payload["campaign_id"] = cid }
616
630
  if let sid = campaign.sessionId { payload["session_id"] = sid }
631
+ } else {
632
+ if let cid = activeCampaigns[key]?.campaignId { payload["campaign_id"] = cid }
617
633
  }
618
634
 
619
635
  post(endpoint: endpoint, payload: payload) { [weak self] result in
620
636
  guard let self = self else { return }
621
637
  switch result {
622
- case .success(let (status, data)):
638
+ case .success(let (status, _)):
623
639
  if 200...299 ~= status {
624
- print("✅ SpotnySDK: \(eventType) sent distance \(String(format: "%.2f", distance))m")
625
- logToFile("\(eventType) sent — beacon \(key) @ \(String(format: "%.2f", distance))m")
640
+ print("✅ SpotnySDK: \(eventType) — \(String(format: "%.2f", distance))m")
641
+ self.logToFile("\(eventType) beacon \(key) @ \(String(format: "%.2f", distance))m")
626
642
  } else if status == 429 {
627
643
  let penalty = Date().addingTimeInterval(10)
628
644
  if isImpression { self.lastImpressionEventSent[key] = penalty }
@@ -634,63 +650,51 @@ public class SpotnyBeaconScanner: NSObject {
634
650
  case .failure(let error):
635
651
  print("❌ SpotnySDK: \(eventType) error — \(error.localizedDescription)")
636
652
  }
637
- if isImpression { self.impressionEventInProgress[key] = false }
638
- else { self.proximityEventInProgress[key] = false }
653
+ self.eventInProgress[progressKey] = false
639
654
  }
640
655
  }
641
656
 
642
657
  private func fetchCampaign(key: String, major: Int, minor: Int) {
658
+ guard sdkToken != nil else { return }
643
659
  guard campaignFetchInProgress[key] != true else { return }
644
660
  guard campaignFetched[key] != true else { return }
645
-
661
+ if let cooldown = campaignFetchCooldown[key], Date() < cooldown { return }
662
+
646
663
  campaignFetchInProgress[key] = true
647
-
648
- let payload: [String: Any] = ["beacon_id": key]
649
-
650
- post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
664
+
665
+ post(endpoint: "\(apiBasePath)/distribute", payload: ["beacon_id": key]) { [weak self] result in
651
666
  guard let self = self else { return }
652
667
  self.campaignFetchInProgress[key] = false
653
-
668
+
654
669
  switch result {
655
670
  case .success(let (status, data)):
656
671
  if 200...299 ~= status {
657
672
  guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
658
- let dObj = json["data"] as? [String: Any] else {
659
- print("⚠️ SpotnySDK: Campaign fetch — invalid response format")
660
- return
661
- }
662
-
663
- var screenId: Int = 0
673
+ let dObj = json["data"] as? [String: Any] else { return }
674
+
664
675
  var campaignId: Int?
665
- var sessionId: String?
676
+ var sessionId: String?
666
677
  var inQueue = false
667
-
668
- if let sid = dObj["screen_id"] as? Int {
669
- screenId = sid
670
- }
671
-
678
+
672
679
  if let campaignObj = dObj["campaign"] as? [String: Any] {
673
680
  campaignId = campaignObj["id"] as? Int
674
- sessionId = campaignObj["session_id"] as? String
675
- inQueue = campaignObj["inQueue"] as? Bool ?? false
681
+ sessionId = campaignObj["session_id"] as? String
682
+ inQueue = campaignObj["inQueue"] as? Bool ?? false
676
683
  }
677
-
678
- let campaign = CampaignData(
679
- screenId: screenId,
680
- campaignId: campaignId,
681
- sessionId: sessionId,
682
- inQueue: inQueue,
683
- major: major,
684
- minor: minor
684
+
685
+ self.activeCampaigns[key] = CampaignData(
686
+ campaignId: campaignId, sessionId: sessionId,
687
+ inQueue: inQueue, major: major, minor: minor
685
688
  )
686
- self.activeCampaigns[key] = campaign
687
689
  self.campaignFetched[key] = true
688
-
689
- print("✅ SpotnySDK: Campaign fetched — screenId=\(screenId), campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
690
+ print("✅ SpotnySDK: Campaign — cid=\(campaignId ?? 0) queued=\(inQueue)")
690
691
  } else {
692
+ // 30s backoff on failure to avoid hammering the backend
693
+ self.campaignFetchCooldown[key] = Date().addingTimeInterval(30)
691
694
  print("❌ SpotnySDK: Campaign fetch failed — status \(status)")
692
695
  }
693
696
  case .failure(let error):
697
+ self.campaignFetchCooldown[key] = Date().addingTimeInterval(30)
694
698
  print("❌ SpotnySDK: Campaign fetch error — \(error.localizedDescription)")
695
699
  }
696
700
  }
@@ -709,21 +713,26 @@ public class SpotnyBeaconScanner: NSObject {
709
713
  // MARK: - State Cleanup
710
714
 
711
715
  private func cleanupBeacon(_ key: String, distance: Double = 0) {
712
- sendProximity(eventType: "PROXIMITY_EXIT", key: key, distance: distance)
716
+ // FIX #6: only send exit if NEARBY was previously emitted for this beacon
717
+ if lastProximityEventSent[key] != nil {
718
+ sendProximity(eventType: "PROXIMITY_EXIT", key: key, distance: distance)
719
+ }
713
720
  activeCampaigns.removeValue(forKey: key)
714
721
  lastProximityEventSent.removeValue(forKey: key)
715
722
  lastProximityDistance.removeValue(forKey: key)
716
723
  lastImpressionEventSent.removeValue(forKey: key)
717
- proximityEventInProgress.removeValue(forKey: key)
718
- impressionEventInProgress.removeValue(forKey: key)
724
+ // FIX #8: unified eventInProgress dict with suffixed keys
725
+ eventInProgress.removeValue(forKey: "\(key):proximity")
726
+ eventInProgress.removeValue(forKey: "\(key):impression")
719
727
  campaignFetchInProgress.removeValue(forKey: key)
720
728
  campaignFetched.removeValue(forKey: key)
721
- print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
729
+ campaignFetchCooldown.removeValue(forKey: key)
730
+ print("🧹 SpotnySDK: Cleaned up \(key)")
722
731
  }
723
732
 
724
733
  private func cleanupAllProximityState() {
725
- // Stagger exit events to avoid firing N simultaneous network requests
726
- let keys = Array(activeCampaigns.keys)
734
+ // FIX #3: use lastProximityEventSent covers beacons even without campaign data
735
+ let keys = Array(lastProximityEventSent.keys)
727
736
  for (index, key) in keys.enumerated() {
728
737
  DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.3) { [weak self] in
729
738
  self?.cleanupBeacon(key)
@@ -805,17 +814,9 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
805
814
 
806
815
  guard distance > 0 && distance <= maxDetectionDistance else { continue }
807
816
 
808
- let isFirst = lastProximityEventSent[key] == nil
809
-
810
- if isFirst {
811
- // First detection — send proximity immediately
812
- sendProximity(eventType: "NEARBY", key: key, distance: distance)
813
- lastProximityDistance[key] = distance
814
- lastProximityEventSent[key] = now
815
- } else if distance >= 1.0,
816
- let lastDist = lastProximityDistance[key],
817
- abs(distance - lastDist) >= proximityDistanceThreshold {
818
- // Distance changed significantly — send proximity update
817
+ // FIX #9: nil lastDist already implies first detection — no separate isFirst needed
818
+ let lastDist = lastProximityDistance[key]
819
+ if lastDist == nil || (distance >= 1.0 && abs(distance - (lastDist ?? 0)) >= proximityDistanceThreshold) {
819
820
  sendProximity(eventType: "NEARBY", key: key, distance: distance)
820
821
  lastProximityDistance[key] = distance
821
822
  lastProximityEventSent[key] = now
@@ -826,17 +827,13 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
826
827
  fetchCampaign(key: key, major: major, minor: minor)
827
828
  }
828
829
 
829
- // Impression heartbeat when user is very close AND campaign exists
830
+ // FIX #10: use ?? .distantPast to eliminate duplicate first-time code path
830
831
  if let campaign = activeCampaigns[key],
831
- let _ = campaign.campaignId,
832
+ campaign.campaignId != nil,
832
833
  !campaign.inQueue,
833
834
  distance <= impressionDistance {
834
- if let last = lastImpressionEventSent[key] {
835
- if now.timeIntervalSince(last) >= impressionEventInterval {
836
- sendImpression(key: key, distance: distance)
837
- lastImpressionEventSent[key] = now
838
- }
839
- } else {
835
+ let lastImp = lastImpressionEventSent[key] ?? .distantPast
836
+ if now.timeIntervalSince(lastImp) >= impressionEventInterval {
840
837
  sendImpression(key: key, distance: distance)
841
838
  lastImpressionEventSent[key] = now
842
839
  }
@@ -847,33 +844,16 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
847
844
  public func beaconManager(_ manager: KTKBeaconManager, didEnter region: KTKBeaconRegion) {
848
845
  print("🎯 SpotnySDK: Entered region \(region.identifier)")
849
846
  eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "enter"])
850
-
851
- var bg: UIBackgroundTaskIdentifier = .invalid
852
- bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
853
- defer {
854
- DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
855
- if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
856
- }
857
- }
847
+ // FIX #1: use helper — avoids defer capturing bg by value
848
+ extendBackgroundTime(duration: 3.0)
858
849
  }
859
850
 
860
851
  public func beaconManager(_ manager: KTKBeaconManager, didExitRegion region: KTKBeaconRegion) {
861
852
  print("🚪 SpotnySDK: Exited region \(region.identifier)")
862
853
  eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "exit"])
863
-
864
- var bg: UIBackgroundTaskIdentifier = .invalid
865
- bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
866
- defer {
867
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
868
- if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
869
- }
870
- }
871
-
872
- let parts = region.identifier.components(separatedBy: "_")
873
- if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
874
- cleanupBeacon(beaconKey(major: major, minor: minor))
875
- }
876
-
854
+ // FIX #1: use helper; FIX #3: cleanupAllProximityState covers all beacons regardless of region format
855
+ extendBackgroundTime(duration: 2.0)
856
+ cleanupAllProximityState()
877
857
  beaconManager.stopRangingBeacons(in: region)
878
858
  }
879
859
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",