spotny-sdk 1.0.6 → 1.0.7
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
|
@@ -269,14 +269,14 @@ Returns `Promise<Object>`.
|
|
|
269
269
|
|
|
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
|
-
3. When a beacon is detected, the SDK immediately sends a `NEARBY` proximity event with
|
|
273
|
-
4.
|
|
274
|
-
5. The SDK stores this campaign data and
|
|
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).
|
|
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`.
|
|
277
277
|
8. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
|
|
278
278
|
|
|
279
|
-
**
|
|
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.
|
|
280
280
|
|
|
281
281
|
All events are tied to the authenticated `identifierId` supplied during `initialize()` — no additional identity calls are required.
|
|
282
282
|
|
|
@@ -70,6 +70,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
70
70
|
private val lastImpressionEventSent = mutableMapOf<String, Long>()
|
|
71
71
|
private val proximityEventInProgress = mutableMapOf<String, Boolean>()
|
|
72
72
|
private val impressionEventInProgress = mutableMapOf<String, Boolean>()
|
|
73
|
+
private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
|
|
74
|
+
private val campaignFetched = mutableMapOf<String, Boolean>()
|
|
73
75
|
|
|
74
76
|
// ── Module registration ───────────────────────────────────────────────────
|
|
75
77
|
|
|
@@ -310,6 +312,11 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
310
312
|
}
|
|
311
313
|
}
|
|
312
314
|
|
|
315
|
+
// Fetch campaign when distance <= 3m
|
|
316
|
+
if (distance <= 3.0 && campaignFetched[key] != true) {
|
|
317
|
+
fetchCampaign(key, major, minor)
|
|
318
|
+
}
|
|
319
|
+
|
|
313
320
|
// Impression heartbeat when user is very close AND campaign exists
|
|
314
321
|
val campaign = activeCampaigns[key]
|
|
315
322
|
if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
|
|
@@ -406,6 +413,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
406
413
|
lastImpressionEventSent.remove(key)
|
|
407
414
|
proximityEventInProgress.remove(key)
|
|
408
415
|
impressionEventInProgress.remove(key)
|
|
416
|
+
campaignFetchInProgress.remove(key)
|
|
417
|
+
campaignFetched.remove(key)
|
|
409
418
|
Log.d(TAG, "Cleaned up state for beacon $key")
|
|
410
419
|
}
|
|
411
420
|
|
|
@@ -541,7 +550,7 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
541
550
|
if (isImpression) impressionEventInProgress[key] = true
|
|
542
551
|
else proximityEventInProgress[key] = true
|
|
543
552
|
|
|
544
|
-
// Build payload —
|
|
553
|
+
// Build payload — proximity events send beacon_id only, impressions include campaign data
|
|
545
554
|
val payload = mutableMapOf<String, Any?>(
|
|
546
555
|
"event_type" to eventType,
|
|
547
556
|
"beacon_id" to key,
|
|
@@ -555,71 +564,74 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
555
564
|
impressionEventInProgress[key] = false
|
|
556
565
|
return
|
|
557
566
|
}
|
|
567
|
+
payload["screen_id"] = campaign.screenId
|
|
558
568
|
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
559
569
|
campaign.sessionId?.let { payload["session_id"] = it }
|
|
560
|
-
} else {
|
|
561
|
-
// Proximity events also send campaign/session if available
|
|
562
|
-
activeCampaigns[key]?.let { campaign ->
|
|
563
|
-
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
564
|
-
campaign.sessionId?.let { payload["session_id"] = it }
|
|
565
|
-
}
|
|
566
570
|
}
|
|
567
571
|
|
|
568
572
|
post(endpoint, payload) { status, body ->
|
|
569
573
|
if (status in 200..299) {
|
|
570
574
|
Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
575
|
+
} else if (status == 429) {
|
|
576
|
+
val penalty = System.currentTimeMillis() + 10_000L
|
|
577
|
+
if (isImpression) lastImpressionEventSent[key] = penalty
|
|
578
|
+
else lastProximityEventSent[key] = penalty
|
|
579
|
+
Log.w(TAG, "$eventType rate-limited (429)")
|
|
580
|
+
} else {
|
|
581
|
+
Log.w(TAG, "$eventType failed — status $status")
|
|
582
|
+
}
|
|
583
|
+
if (isImpression) impressionEventInProgress[key] = false
|
|
584
|
+
else proximityEventInProgress[key] = false
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private fun fetchCampaign(key: String, major: Int, minor: Int) {
|
|
589
|
+
if (campaignFetchInProgress[key] == true) return
|
|
590
|
+
if (campaignFetched[key] == true) return
|
|
591
|
+
|
|
592
|
+
campaignFetchInProgress[key] = true
|
|
593
|
+
|
|
594
|
+
val payload = mapOf("beacon_id" to key)
|
|
595
|
+
|
|
596
|
+
post("$apiBasePath/distribute", payload) { status, body ->
|
|
597
|
+
campaignFetchInProgress[key] = false
|
|
598
|
+
|
|
599
|
+
if (status in 200..299) {
|
|
600
|
+
try {
|
|
601
|
+
val json = parseJsonObject(body)
|
|
602
|
+
val dataObj = json?.get("data") as? Map<*, *>
|
|
603
|
+
|
|
604
|
+
if (dataObj != null) {
|
|
605
|
+
val screenId = (dataObj["screen_id"] as? Number)?.toInt() ?: 0
|
|
578
606
|
var campaignId: Int? = null
|
|
579
607
|
var sessionId: String? = null
|
|
580
608
|
var inQueue = false
|
|
581
609
|
|
|
582
|
-
|
|
583
|
-
val eventObj = dataObj?.get("event") as? Map<*, *>
|
|
584
|
-
if (eventObj != null) {
|
|
585
|
-
sessionId = eventObj["session_id"] as? String
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Extract from campaign object
|
|
589
|
-
val campaignObj = dataObj?.get("campaign") as? Map<*, *>
|
|
610
|
+
val campaignObj = dataObj["campaign"] as? Map<*, *>
|
|
590
611
|
if (campaignObj != null) {
|
|
591
612
|
campaignId = (campaignObj["id"] as? Number)?.toInt()
|
|
613
|
+
sessionId = campaignObj["session_id"] as? String
|
|
592
614
|
inQueue = campaignObj["inQueue"] as? Boolean ?: false
|
|
593
615
|
}
|
|
594
616
|
|
|
595
|
-
// Store campaign data
|
|
596
|
-
// Parse major/minor from key
|
|
597
|
-
val parts = key.split("_")
|
|
598
|
-
val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
|
|
599
|
-
val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
|
|
600
|
-
|
|
601
617
|
activeCampaigns[key] = CampaignData(
|
|
618
|
+
screenId = screenId,
|
|
602
619
|
campaignId = campaignId,
|
|
603
620
|
sessionId = sessionId,
|
|
604
621
|
inQueue = inQueue,
|
|
605
622
|
major = major,
|
|
606
623
|
minor = minor
|
|
607
624
|
)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
Log.
|
|
625
|
+
campaignFetched[key] = true
|
|
626
|
+
|
|
627
|
+
Log.d(TAG, "Campaign fetched — screenId=$screenId, campaignId=$campaignId, sessionId=$sessionId, inQueue=$inQueue")
|
|
611
628
|
}
|
|
629
|
+
} catch (e: Exception) {
|
|
630
|
+
Log.w(TAG, "Error parsing campaign response: ${e.message}")
|
|
612
631
|
}
|
|
613
|
-
} else if (status == 429) {
|
|
614
|
-
val penalty = System.currentTimeMillis() + 10_000L
|
|
615
|
-
if (isImpression) lastImpressionEventSent[key] = penalty
|
|
616
|
-
else lastProximityEventSent[key] = penalty
|
|
617
|
-
Log.w(TAG, "$eventType rate-limited (429)")
|
|
618
632
|
} else {
|
|
619
|
-
Log.w(TAG, "
|
|
633
|
+
Log.w(TAG, "Campaign fetch failed — status $status")
|
|
620
634
|
}
|
|
621
|
-
if (isImpression) impressionEventInProgress[key] = false
|
|
622
|
-
else proximityEventInProgress[key] = false
|
|
623
635
|
}
|
|
624
636
|
}
|
|
625
637
|
|
|
@@ -20,8 +20,9 @@ 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
|
|
23
24
|
let campaignId: Int? // nil when no active campaign
|
|
24
|
-
let sessionId: String? // set after
|
|
25
|
+
let sessionId: String? // set after campaign fetch response
|
|
25
26
|
let inQueue: Bool // campaign is queued — skip impressions
|
|
26
27
|
let major: Int
|
|
27
28
|
let minor: Int
|
|
@@ -92,6 +93,8 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
92
93
|
private var lastImpressionEventSent: [String: Date] = [:]
|
|
93
94
|
private var proximityEventInProgress: [String: Bool] = [:]
|
|
94
95
|
private var impressionEventInProgress: [String: Bool] = [:]
|
|
96
|
+
private var campaignFetchInProgress: [String: Bool] = [:]
|
|
97
|
+
private var campaignFetched: [String: Bool] = [:]
|
|
95
98
|
|
|
96
99
|
// MARK: - Init
|
|
97
100
|
|
|
@@ -593,7 +596,7 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
593
596
|
if isImpression { impressionEventInProgress[key] = true }
|
|
594
597
|
else { proximityEventInProgress[key] = true }
|
|
595
598
|
|
|
596
|
-
// Build payload —
|
|
599
|
+
// Build payload — proximity events send beacon_id only, impressions include campaign data
|
|
597
600
|
var payload: [String: Any] = [
|
|
598
601
|
"event_type": eventType,
|
|
599
602
|
"beacon_id": key,
|
|
@@ -608,14 +611,9 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
608
611
|
impressionEventInProgress[key] = false
|
|
609
612
|
return
|
|
610
613
|
}
|
|
614
|
+
payload["screen_id"] = campaign.screenId
|
|
611
615
|
if let cid = campaign.campaignId { payload["campaign_id"] = cid }
|
|
612
616
|
if let sid = campaign.sessionId { payload["session_id"] = sid }
|
|
613
|
-
} else {
|
|
614
|
-
// Proximity events also send campaign/session if available
|
|
615
|
-
if let campaign = activeCampaigns[key] {
|
|
616
|
-
if let cid = campaign.campaignId { payload["campaign_id"] = cid }
|
|
617
|
-
if let sid = campaign.sessionId { payload["session_id"] = sid }
|
|
618
|
-
}
|
|
619
617
|
}
|
|
620
618
|
|
|
621
619
|
post(endpoint: endpoint, payload: payload) { [weak self] result in
|
|
@@ -625,43 +623,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
625
623
|
if 200...299 ~= status {
|
|
626
624
|
print("✅ SpotnySDK: \(eventType) sent — distance \(String(format: "%.2f", distance))m")
|
|
627
625
|
logToFile("✅ \(eventType) sent — beacon \(key) @ \(String(format: "%.2f", distance))m")
|
|
628
|
-
|
|
629
|
-
// Parse campaign_id, session_id from proximity response
|
|
630
|
-
if !isImpression,
|
|
631
|
-
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
632
|
-
let dObj = json["data"] as? [String: Any] {
|
|
633
|
-
|
|
634
|
-
var campaignId: Int?
|
|
635
|
-
var sessionId: String?
|
|
636
|
-
var inQueue = false
|
|
637
|
-
|
|
638
|
-
// Extract session_id from event object
|
|
639
|
-
if let ev = dObj["event"] as? [String: Any] {
|
|
640
|
-
sessionId = ev["session_id"] as? String
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Extract from campaign object
|
|
644
|
-
if let campaignObj = dObj["campaign"] as? [String: Any] {
|
|
645
|
-
campaignId = campaignObj["id"] as? Int
|
|
646
|
-
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Store campaign data
|
|
650
|
-
// Parse major/minor from key
|
|
651
|
-
let parts = key.components(separatedBy: "_")
|
|
652
|
-
let major = parts.count > 0 ? Int(parts[0]) ?? 0 : 0
|
|
653
|
-
let minor = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
|
|
654
|
-
|
|
655
|
-
let updated = CampaignData(
|
|
656
|
-
campaignId: campaignId,
|
|
657
|
-
sessionId: sessionId,
|
|
658
|
-
inQueue: inQueue,
|
|
659
|
-
major: major,
|
|
660
|
-
minor: minor
|
|
661
|
-
)
|
|
662
|
-
self.activeCampaigns[key] = updated
|
|
663
|
-
print("✅ SpotnySDK: Stored campaign — campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
|
|
664
|
-
}
|
|
665
626
|
} else if status == 429 {
|
|
666
627
|
let penalty = Date().addingTimeInterval(10)
|
|
667
628
|
if isImpression { self.lastImpressionEventSent[key] = penalty }
|
|
@@ -678,6 +639,63 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
678
639
|
}
|
|
679
640
|
}
|
|
680
641
|
|
|
642
|
+
private func fetchCampaign(key: String, major: Int, minor: Int) {
|
|
643
|
+
guard campaignFetchInProgress[key] != true else { return }
|
|
644
|
+
guard campaignFetched[key] != true else { return }
|
|
645
|
+
|
|
646
|
+
campaignFetchInProgress[key] = true
|
|
647
|
+
|
|
648
|
+
let payload: [String: Any] = ["beacon_id": key]
|
|
649
|
+
|
|
650
|
+
post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
|
|
651
|
+
guard let self = self else { return }
|
|
652
|
+
self.campaignFetchInProgress[key] = false
|
|
653
|
+
|
|
654
|
+
switch result {
|
|
655
|
+
case .success(let (status, data)):
|
|
656
|
+
if 200...299 ~= status {
|
|
657
|
+
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
|
|
664
|
+
var campaignId: Int?
|
|
665
|
+
var sessionId: String?
|
|
666
|
+
var inQueue = false
|
|
667
|
+
|
|
668
|
+
if let sid = dObj["screen_id"] as? Int {
|
|
669
|
+
screenId = sid
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if let campaignObj = dObj["campaign"] as? [String: Any] {
|
|
673
|
+
campaignId = campaignObj["id"] as? Int
|
|
674
|
+
sessionId = campaignObj["session_id"] as? String
|
|
675
|
+
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let campaign = CampaignData(
|
|
679
|
+
screenId: screenId,
|
|
680
|
+
campaignId: campaignId,
|
|
681
|
+
sessionId: sessionId,
|
|
682
|
+
inQueue: inQueue,
|
|
683
|
+
major: major,
|
|
684
|
+
minor: minor
|
|
685
|
+
)
|
|
686
|
+
self.activeCampaigns[key] = campaign
|
|
687
|
+
self.campaignFetched[key] = true
|
|
688
|
+
|
|
689
|
+
print("✅ SpotnySDK: Campaign fetched — screenId=\(screenId), campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
|
|
690
|
+
} else {
|
|
691
|
+
print("❌ SpotnySDK: Campaign fetch failed — status \(status)")
|
|
692
|
+
}
|
|
693
|
+
case .failure(let error):
|
|
694
|
+
print("❌ SpotnySDK: Campaign fetch error — \(error.localizedDescription)")
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
681
699
|
private func sendProximity(eventType: String, key: String, distance: Double) {
|
|
682
700
|
sendTracking(eventType: eventType, key: key, distance: distance,
|
|
683
701
|
endpoint: "\(apiBasePath)/proximity")
|
|
@@ -698,6 +716,8 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
698
716
|
lastImpressionEventSent.removeValue(forKey: key)
|
|
699
717
|
proximityEventInProgress.removeValue(forKey: key)
|
|
700
718
|
impressionEventInProgress.removeValue(forKey: key)
|
|
719
|
+
campaignFetchInProgress.removeValue(forKey: key)
|
|
720
|
+
campaignFetched.removeValue(forKey: key)
|
|
701
721
|
print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
|
|
702
722
|
}
|
|
703
723
|
|
|
@@ -800,6 +820,11 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
|
|
|
800
820
|
lastProximityDistance[key] = distance
|
|
801
821
|
lastProximityEventSent[key] = now
|
|
802
822
|
}
|
|
823
|
+
|
|
824
|
+
// Fetch campaign when distance <= 3m
|
|
825
|
+
if distance <= 3.0 && campaignFetched[key] != true {
|
|
826
|
+
fetchCampaign(key: key, major: major, minor: minor)
|
|
827
|
+
}
|
|
803
828
|
|
|
804
829
|
// Impression heartbeat when user is very close AND campaign exists
|
|
805
830
|
if let campaign = activeCampaigns[key],
|