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 (`
|
|
274
|
-
5. The SDK stores this campaign data
|
|
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 `
|
|
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
|
-
**
|
|
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
|
|
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 — 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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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:
|
|
91
|
-
private var lastProximityEventSent:
|
|
92
|
-
private var lastProximityDistance:
|
|
93
|
-
private var lastImpressionEventSent:
|
|
94
|
-
|
|
95
|
-
private var
|
|
96
|
-
private var campaignFetchInProgress:
|
|
97
|
-
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
|
|
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
|
-
//
|
|
132
|
-
guard
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
sdkTokenExpiry = Date(timeIntervalSince1970:
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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,
|
|
638
|
+
case .success(let (status, _)):
|
|
623
639
|
if 200...299 ~= status {
|
|
624
|
-
print("✅ SpotnySDK: \(eventType)
|
|
625
|
-
logToFile("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
675
|
-
inQueue
|
|
681
|
+
sessionId = campaignObj["session_id"] as? String
|
|
682
|
+
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
676
683
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
|
|
729
|
+
campaignFetchCooldown.removeValue(forKey: key)
|
|
730
|
+
print("🧹 SpotnySDK: Cleaned up \(key)")
|
|
722
731
|
}
|
|
723
732
|
|
|
724
733
|
private func cleanupAllProximityState() {
|
|
725
|
-
//
|
|
726
|
-
let keys = Array(
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
if
|
|
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
|
-
//
|
|
830
|
+
// FIX #10: use ?? .distantPast to eliminate duplicate first-time code path
|
|
830
831
|
if let campaign = activeCampaigns[key],
|
|
831
|
-
|
|
832
|
+
campaign.campaignId != nil,
|
|
832
833
|
!campaign.inQueue,
|
|
833
834
|
distance <= impressionDistance {
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
-
|
|
865
|
-
|
|
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
|
|