spotny-sdk 1.0.8 → 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.
@@ -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 — all events send beacon_id, add campaign data when available
554
546
  val payload = mutableMapOf<String, Any?>(
555
547
  "event_type" to eventType,
556
548
  "beacon_id" to key,
@@ -558,20 +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
558
  campaign.campaignId?.let { payload["campaign_id"] = it }
568
559
  campaign.sessionId?.let { payload["session_id"] = it }
569
560
  } else {
570
- // Proximity events also include campaign_id if available
571
561
  activeCampaigns[key]?.campaignId?.let { payload["campaign_id"] = it }
572
562
  }
573
563
 
574
- post(endpoint, payload) { status, body ->
564
+ post(endpoint, payload) { status, _ ->
575
565
  if (status in 200..299) {
576
566
  Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
577
567
  } else if (status == 429) {
@@ -582,55 +572,43 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
582
572
  } else {
583
573
  Log.w(TAG, "$eventType failed — status $status")
584
574
  }
585
- if (isImpression) impressionEventInProgress[key] = false
586
- else proximityEventInProgress[key] = false
575
+ eventInProgress[progressKey] = false
587
576
  }
588
577
  }
589
578
 
590
579
  private fun fetchCampaign(key: String, major: Int, minor: Int) {
580
+ if (sdkToken == null) return // FIX #4: no-op without JWT
591
581
  if (campaignFetchInProgress[key] == true) return
592
582
  if (campaignFetched[key] == true) return
593
-
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
+
594
587
  campaignFetchInProgress[key] = true
595
-
596
- val payload = mapOf("beacon_id" to key)
597
-
598
- post("$apiBasePath/distribute", payload) { status, body ->
588
+
589
+ post("$apiBasePath/distribute", mapOf("beacon_id" to key)) { status, body ->
599
590
  campaignFetchInProgress[key] = false
600
-
591
+
601
592
  if (status in 200..299) {
602
593
  try {
603
- val json = parseJsonObject(body)
594
+ val json = parseJsonObject(body)
604
595
  val dataObj = json?.get("data") as? Map<*, *>
605
-
606
596
  if (dataObj != null) {
607
- var campaignId: Int? = null
608
- var sessionId: String? = null
609
- var inQueue = false
610
-
611
597
  val campaignObj = dataObj["campaign"] as? Map<*, *>
612
- if (campaignObj != null) {
613
- campaignId = (campaignObj["id"] as? Number)?.toInt()
614
- sessionId = campaignObj["session_id"] as? String
615
- inQueue = campaignObj["inQueue"] as? Boolean ?: false
616
- }
617
-
618
- activeCampaigns[key] = CampaignData(
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 — 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
  }
@@ -86,14 +86,15 @@ public class SpotnyBeaconScanner: NSObject {
86
86
  private var debounceInterval: TimeInterval = 5.0
87
87
 
88
88
  // ── Per-beacon tracking state ──────────────────────────────────────────────
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
- private var proximityEventInProgress: [String: Bool] = [:]
94
- private var impressionEventInProgress: [String: Bool] = [:]
95
- private var campaignFetchInProgress: [String: Bool] = [:]
96
- 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
97
98
 
98
99
  // MARK: - Init
99
100
 
@@ -127,21 +128,25 @@ public class SpotnyBeaconScanner: NSObject {
127
128
  }
128
129
 
129
130
  private func resumeStoredSession() {
130
- // Resume scanning only if a valid session was previously active
131
- guard let ts = UserDefaults.standard.object(forKey: "SpotnySDK_sessionTimestamp") as? Double else { return }
132
- let age = Date().timeIntervalSince1970 - ts
133
- if age > sessionTTL {
134
- print("⚠️ SpotnySDK: Stale session (\(Int(age / 3600))h old) — discarding")
135
- clearStoredSession()
136
- 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
+ }
137
142
  }
138
- // Restore persisted JWT + credentials so all API calls work without initialize()
143
+
139
144
  sdkToken = keychainRead(key: "SpotnySDK_jwt")
140
145
  sdkCredential = keychainRead(key: "SpotnySDK_sdkCredential")
141
146
  apiKey = keychainRead(key: "SpotnySDK_apiKey")
142
147
  identifierId = keychainRead(key: "SpotnySDK_identifierId")
143
- if let expiryStr = keychainRead(key: "SpotnySDK_jwtExpiry"), let ts = Double(expiryStr) {
144
- sdkTokenExpiry = Date(timeIntervalSince1970: ts)
148
+ if let expiryStr = keychainRead(key: "SpotnySDK_jwtExpiry"), let expTs = Double(expiryStr) {
149
+ sdkTokenExpiry = Date(timeIntervalSince1970: expTs)
145
150
  }
146
151
  print("🔄 SpotnySDK: Resuming session (device: \(getDeviceId()))")
147
152
  startPersistentScanning()
@@ -149,9 +154,9 @@ public class SpotnyBeaconScanner: NSObject {
149
154
  }
150
155
 
151
156
  private func clearStoredSession() {
152
- // Only clears the scanning session — identity (Keychain) is intentionally kept
153
157
  UserDefaults.standard.removeObject(forKey: "SpotnySDK_sessionTimestamp")
154
158
  UserDefaults.standard.synchronize()
159
+ keychainDelete(key: "SpotnySDK_sessionActive")
155
160
  }
156
161
 
157
162
  // MARK: - ObjC-Exposed Methods (called from SpotnySdk.mm)
@@ -168,6 +173,7 @@ public class SpotnyBeaconScanner: NSObject {
168
173
 
169
174
  UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "SpotnySDK_sessionTimestamp")
170
175
  UserDefaults.standard.synchronize()
176
+ keychainWrite(key: "SpotnySDK_sessionActive", value: "1")
171
177
 
172
178
  let status = locationManager.authorizationStatus
173
179
  if status == .notDetermined {
@@ -188,11 +194,10 @@ public class SpotnyBeaconScanner: NSObject {
188
194
  beaconManager.stopMonitoringForAllRegions()
189
195
  beaconManager.stopRangingBeaconsInAllRegions()
190
196
  cleanupAllProximityState()
191
-
192
197
  clearStoredSession()
193
-
198
+ lastRangedSignature = ""
199
+ lastRangedEmit = .distantPast
194
200
  scanning = false
195
-
196
201
  print("⏹️ SpotnySDK: Stopped scanning")
197
202
  resolve("Scanning stopped")
198
203
  }
@@ -582,20 +587,32 @@ public class SpotnyBeaconScanner: NSObject {
582
587
 
583
588
  // MARK: - Tracking
584
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
+
585
602
  private func sendTracking(
586
603
  eventType: String,
587
604
  key: String,
588
605
  distance: Double,
589
606
  endpoint: String
590
607
  ) {
591
- let isImpression = eventType == "IMPRESSION_HEARTBEAT"
592
- let inProg = isImpression ? impressionEventInProgress[key] : proximityEventInProgress[key]
593
- 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 }
594
610
 
595
- if isImpression { impressionEventInProgress[key] = true }
596
- 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
597
615
 
598
- // Build payload — all events send beacon_id, add campaign data when available
599
616
  var payload: [String: Any] = [
600
617
  "event_type": eventType,
601
618
  "beacon_id": key,
@@ -603,30 +620,25 @@ public class SpotnyBeaconScanner: NSObject {
603
620
  ]
604
621
 
605
622
  if isImpression {
606
- // Impressions require campaign to exist
607
623
  guard let campaign = activeCampaigns[key],
608
624
  let _ = campaign.campaignId,
609
625
  !campaign.inQueue else {
610
- impressionEventInProgress[key] = false
626
+ eventInProgress[progressKey] = false
611
627
  return
612
628
  }
613
629
  if let cid = campaign.campaignId { payload["campaign_id"] = cid }
614
630
  if let sid = campaign.sessionId { payload["session_id"] = sid }
615
631
  } else {
616
- // Proximity events also include campaign_id if available
617
- if let campaign = activeCampaigns[key],
618
- let cid = campaign.campaignId {
619
- payload["campaign_id"] = cid
620
- }
632
+ if let cid = activeCampaigns[key]?.campaignId { payload["campaign_id"] = cid }
621
633
  }
622
634
 
623
635
  post(endpoint: endpoint, payload: payload) { [weak self] result in
624
636
  guard let self = self else { return }
625
637
  switch result {
626
- case .success(let (status, data)):
638
+ case .success(let (status, _)):
627
639
  if 200...299 ~= status {
628
- print("✅ SpotnySDK: \(eventType) sent distance \(String(format: "%.2f", distance))m")
629
- 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")
630
642
  } else if status == 429 {
631
643
  let penalty = Date().addingTimeInterval(10)
632
644
  if isImpression { self.lastImpressionEventSent[key] = penalty }
@@ -638,57 +650,51 @@ public class SpotnyBeaconScanner: NSObject {
638
650
  case .failure(let error):
639
651
  print("❌ SpotnySDK: \(eventType) error — \(error.localizedDescription)")
640
652
  }
641
- if isImpression { self.impressionEventInProgress[key] = false }
642
- else { self.proximityEventInProgress[key] = false }
653
+ self.eventInProgress[progressKey] = false
643
654
  }
644
655
  }
645
656
 
646
657
  private func fetchCampaign(key: String, major: Int, minor: Int) {
658
+ guard sdkToken != nil else { return }
647
659
  guard campaignFetchInProgress[key] != true else { return }
648
660
  guard campaignFetched[key] != true else { return }
649
-
661
+ if let cooldown = campaignFetchCooldown[key], Date() < cooldown { return }
662
+
650
663
  campaignFetchInProgress[key] = true
651
-
652
- let payload: [String: Any] = ["beacon_id": key]
653
-
654
- post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
664
+
665
+ post(endpoint: "\(apiBasePath)/distribute", payload: ["beacon_id": key]) { [weak self] result in
655
666
  guard let self = self else { return }
656
667
  self.campaignFetchInProgress[key] = false
657
-
668
+
658
669
  switch result {
659
670
  case .success(let (status, data)):
660
671
  if 200...299 ~= status {
661
672
  guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
662
- let dObj = json["data"] as? [String: Any] else {
663
- print("⚠️ SpotnySDK: Campaign fetch — invalid response format")
664
- return
665
- }
666
-
673
+ let dObj = json["data"] as? [String: Any] else { return }
674
+
667
675
  var campaignId: Int?
668
- var sessionId: String?
676
+ var sessionId: String?
669
677
  var inQueue = false
670
-
678
+
671
679
  if let campaignObj = dObj["campaign"] as? [String: Any] {
672
680
  campaignId = campaignObj["id"] as? Int
673
- sessionId = campaignObj["session_id"] as? String
674
- inQueue = campaignObj["inQueue"] as? Bool ?? false
681
+ sessionId = campaignObj["session_id"] as? String
682
+ inQueue = campaignObj["inQueue"] as? Bool ?? false
675
683
  }
676
-
677
- let campaign = CampaignData(
678
- campaignId: campaignId,
679
- sessionId: sessionId,
680
- inQueue: inQueue,
681
- major: major,
682
- minor: minor
684
+
685
+ self.activeCampaigns[key] = CampaignData(
686
+ campaignId: campaignId, sessionId: sessionId,
687
+ inQueue: inQueue, major: major, minor: minor
683
688
  )
684
- self.activeCampaigns[key] = campaign
685
689
  self.campaignFetched[key] = true
686
-
687
- print("✅ SpotnySDK: Campaign fetched — campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
690
+ print("✅ SpotnySDK: Campaign — cid=\(campaignId ?? 0) queued=\(inQueue)")
688
691
  } else {
692
+ // 30s backoff on failure to avoid hammering the backend
693
+ self.campaignFetchCooldown[key] = Date().addingTimeInterval(30)
689
694
  print("❌ SpotnySDK: Campaign fetch failed — status \(status)")
690
695
  }
691
696
  case .failure(let error):
697
+ self.campaignFetchCooldown[key] = Date().addingTimeInterval(30)
692
698
  print("❌ SpotnySDK: Campaign fetch error — \(error.localizedDescription)")
693
699
  }
694
700
  }
@@ -707,21 +713,26 @@ public class SpotnyBeaconScanner: NSObject {
707
713
  // MARK: - State Cleanup
708
714
 
709
715
  private func cleanupBeacon(_ key: String, distance: Double = 0) {
710
- 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
+ }
711
720
  activeCampaigns.removeValue(forKey: key)
712
721
  lastProximityEventSent.removeValue(forKey: key)
713
722
  lastProximityDistance.removeValue(forKey: key)
714
723
  lastImpressionEventSent.removeValue(forKey: key)
715
- proximityEventInProgress.removeValue(forKey: key)
716
- 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")
717
727
  campaignFetchInProgress.removeValue(forKey: key)
718
728
  campaignFetched.removeValue(forKey: key)
719
- print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
729
+ campaignFetchCooldown.removeValue(forKey: key)
730
+ print("🧹 SpotnySDK: Cleaned up \(key)")
720
731
  }
721
732
 
722
733
  private func cleanupAllProximityState() {
723
- // Stagger exit events to avoid firing N simultaneous network requests
724
- let keys = Array(activeCampaigns.keys)
734
+ // FIX #3: use lastProximityEventSent covers beacons even without campaign data
735
+ let keys = Array(lastProximityEventSent.keys)
725
736
  for (index, key) in keys.enumerated() {
726
737
  DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.3) { [weak self] in
727
738
  self?.cleanupBeacon(key)
@@ -803,17 +814,9 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
803
814
 
804
815
  guard distance > 0 && distance <= maxDetectionDistance else { continue }
805
816
 
806
- let isFirst = lastProximityEventSent[key] == nil
807
-
808
- if isFirst {
809
- // First detection — send proximity immediately
810
- sendProximity(eventType: "NEARBY", key: key, distance: distance)
811
- lastProximityDistance[key] = distance
812
- lastProximityEventSent[key] = now
813
- } else if distance >= 1.0,
814
- let lastDist = lastProximityDistance[key],
815
- abs(distance - lastDist) >= proximityDistanceThreshold {
816
- // 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) {
817
820
  sendProximity(eventType: "NEARBY", key: key, distance: distance)
818
821
  lastProximityDistance[key] = distance
819
822
  lastProximityEventSent[key] = now
@@ -824,17 +827,13 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
824
827
  fetchCampaign(key: key, major: major, minor: minor)
825
828
  }
826
829
 
827
- // Impression heartbeat when user is very close AND campaign exists
830
+ // FIX #10: use ?? .distantPast to eliminate duplicate first-time code path
828
831
  if let campaign = activeCampaigns[key],
829
- let _ = campaign.campaignId,
832
+ campaign.campaignId != nil,
830
833
  !campaign.inQueue,
831
834
  distance <= impressionDistance {
832
- if let last = lastImpressionEventSent[key] {
833
- if now.timeIntervalSince(last) >= impressionEventInterval {
834
- sendImpression(key: key, distance: distance)
835
- lastImpressionEventSent[key] = now
836
- }
837
- } else {
835
+ let lastImp = lastImpressionEventSent[key] ?? .distantPast
836
+ if now.timeIntervalSince(lastImp) >= impressionEventInterval {
838
837
  sendImpression(key: key, distance: distance)
839
838
  lastImpressionEventSent[key] = now
840
839
  }
@@ -845,33 +844,16 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
845
844
  public func beaconManager(_ manager: KTKBeaconManager, didEnter region: KTKBeaconRegion) {
846
845
  print("🎯 SpotnySDK: Entered region \(region.identifier)")
847
846
  eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "enter"])
848
-
849
- var bg: UIBackgroundTaskIdentifier = .invalid
850
- bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
851
- defer {
852
- DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
853
- if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
854
- }
855
- }
847
+ // FIX #1: use helper — avoids defer capturing bg by value
848
+ extendBackgroundTime(duration: 3.0)
856
849
  }
857
850
 
858
851
  public func beaconManager(_ manager: KTKBeaconManager, didExitRegion region: KTKBeaconRegion) {
859
852
  print("🚪 SpotnySDK: Exited region \(region.identifier)")
860
853
  eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "exit"])
861
-
862
- var bg: UIBackgroundTaskIdentifier = .invalid
863
- bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
864
- defer {
865
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
866
- if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
867
- }
868
- }
869
-
870
- let parts = region.identifier.components(separatedBy: "_")
871
- if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
872
- cleanupBeacon(beaconKey(major: major, minor: minor))
873
- }
874
-
854
+ // FIX #1: use helper; FIX #3: cleanupAllProximityState covers all beacons regardless of region format
855
+ extendBackgroundTime(duration: 2.0)
856
+ cleanupAllProximityState()
875
857
  beaconManager.stopRangingBeacons(in: region)
876
858
  }
877
859
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.8",
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",