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
|
|
68
|
-
private val lastProximityEventSent
|
|
69
|
-
private val lastProximityDistance
|
|
70
|
-
private val lastImpressionEventSent
|
|
71
|
-
|
|
72
|
-
private val
|
|
73
|
-
private val campaignFetchInProgress
|
|
74
|
-
private val campaignFetched
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
411
|
+
campaignFetchCooldown.remove(key)
|
|
412
|
+
Log.d(TAG, "Cleaned up $key")
|
|
419
413
|
}
|
|
420
414
|
|
|
421
415
|
private fun cleanupAllState() {
|
|
422
|
-
//
|
|
423
|
-
val keys =
|
|
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
|
|
548
|
-
if (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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:
|
|
90
|
-
private var lastProximityEventSent:
|
|
91
|
-
private var lastProximityDistance:
|
|
92
|
-
private var lastImpressionEventSent:
|
|
93
|
-
|
|
94
|
-
private var
|
|
95
|
-
private var campaignFetchInProgress:
|
|
96
|
-
private var campaignFetched:
|
|
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
|
-
//
|
|
131
|
-
guard
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
sdkTokenExpiry = Date(timeIntervalSince1970:
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
638
|
+
case .success(let (status, _)):
|
|
627
639
|
if 200...299 ~= status {
|
|
628
|
-
print("✅ SpotnySDK: \(eventType)
|
|
629
|
-
logToFile("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
return
|
|
665
|
-
}
|
|
666
|
-
|
|
673
|
+
let dObj = json["data"] as? [String: Any] else { return }
|
|
674
|
+
|
|
667
675
|
var campaignId: Int?
|
|
668
|
-
var sessionId:
|
|
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
|
|
674
|
-
inQueue
|
|
681
|
+
sessionId = campaignObj["session_id"] as? String
|
|
682
|
+
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
675
683
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
campaignId: campaignId,
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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
|
-
|
|
729
|
+
campaignFetchCooldown.removeValue(forKey: key)
|
|
730
|
+
print("🧹 SpotnySDK: Cleaned up \(key)")
|
|
720
731
|
}
|
|
721
732
|
|
|
722
733
|
private func cleanupAllProximityState() {
|
|
723
|
-
//
|
|
724
|
-
let keys = Array(
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
if
|
|
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
|
-
//
|
|
830
|
+
// FIX #10: use ?? .distantPast to eliminate duplicate first-time code path
|
|
828
831
|
if let campaign = activeCampaigns[key],
|
|
829
|
-
|
|
832
|
+
campaign.campaignId != nil,
|
|
830
833
|
!campaign.inQueue,
|
|
831
834
|
distance <= impressionDistance {
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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
|
|