spotny-sdk 0.2.0

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.

Potentially problematic release.


This version of spotny-sdk might be problematic. Click here for more details.

@@ -0,0 +1,681 @@
1
+ //
2
+ // SpotnyBeaconScanner.swift
3
+ // SpotnySdk
4
+ //
5
+ // Core iBeacon scanning logic using Kontakt.io SDK.
6
+ // Handles campaign fetching and proximity / impression tracking against
7
+ // the Spotny backend.
8
+ //
9
+
10
+ import Foundation
11
+ import CoreLocation
12
+ import KontaktSDK
13
+ import UserNotifications
14
+
15
+ // MARK: - Public ObjC-visible typealias for the event callback block
16
+
17
+ public typealias SpotnyEventCallback = @convention(block) (_ name: String, _ body: [String: Any]) -> Void
18
+
19
+ // MARK: - Internal data structures
20
+
21
+ private struct CampaignData {
22
+ let campaignId: Int? // nil when no active campaign
23
+ let screenId: Int
24
+ let sessionId: String? // set after first proximity response
25
+ let inQueue: Bool // campaign is queued — skip impressions
26
+ let major: Int
27
+ let minor: Int
28
+ }
29
+
30
+ // MARK: - SpotnyBeaconScanner
31
+
32
+ @objc(SpotnyBeaconScanner)
33
+ public class SpotnyBeaconScanner: NSObject {
34
+
35
+ // ── Callback to send events back to SpotnySdk.mm ─────────────────────────
36
+ private var eventCallback: SpotnyEventCallback?
37
+
38
+ // ── Managers ──────────────────────────────────────────────────────────────
39
+ private var beaconManager: KTKBeaconManager!
40
+ private var locationManager: CLLocationManager!
41
+
42
+ // ── Session state ─────────────────────────────────────────────────────────
43
+ private var currentUserUUID: String?
44
+ private var userId: Int?
45
+ private var scanning: Bool = false
46
+
47
+ // ── Configuration (overridable via configure()) ────────────────────────────
48
+ private var backendURL: String = "https://api.spotny.app"
49
+ private var maxDetectionDistance: Double = 8.0
50
+ private var kontaktAPIKey: String = "mgrz08TOKNHafeY02cWIs9mxUHbynNQJ"
51
+
52
+ // ── Beacon UUID (standard Kontakt.io default) ─────────────────────────────
53
+ private let beaconUUID = UUID(uuidString: "f7826da6-4fa2-4e98-8024-bc5b71e0893e")!
54
+
55
+ // ── Timing constants ──────────────────────────────────────────────────────
56
+ private let campaignFetchCooldown: TimeInterval = 5.0
57
+ private let proximityDistanceThreshold: Double = 0.75
58
+ private let impressionEventInterval: TimeInterval = 10.0
59
+ private let impressionDistance: Double = 2.0
60
+ private var debounceInterval: TimeInterval = 5.0
61
+
62
+ // ── Per-beacon tracking state ──────────────────────────────────────────────
63
+ private var activeCampaigns: [String: CampaignData] = [:]
64
+ private var lastProximityEventSent: [String: Date] = [:]
65
+ private var lastProximityDistance: [String: Double] = [:]
66
+ private var lastImpressionEventSent: [String: Date] = [:]
67
+ private var lastCampaignFetchAttempt: [String: Date] = [:]
68
+ private var fetchInProgress: [String: Bool] = [:]
69
+ private var proximityEventInProgress: [String: Bool] = [:]
70
+ private var impressionEventInProgress: [String: Bool] = [:]
71
+
72
+ // MARK: - Init
73
+
74
+ @objc
75
+ public init(eventCallback: @escaping SpotnyEventCallback) {
76
+ self.eventCallback = eventCallback
77
+ super.init()
78
+ setupManagers()
79
+ resumeStoredSession()
80
+ }
81
+
82
+ // MARK: - Setup
83
+
84
+ private func setupManagers() {
85
+ // Initialise the Kontakt.io SDK with the API key before creating any manager
86
+ Kontakt.setAPIKey(kontaktAPIKey)
87
+ print("✅ SpotnySDK: Kontakt.io SDK initialised")
88
+
89
+ locationManager = CLLocationManager()
90
+ locationManager.delegate = self
91
+ locationManager.allowsBackgroundLocationUpdates = true
92
+ locationManager.pausesLocationUpdatesAutomatically = false
93
+
94
+ beaconManager = KTKBeaconManager(delegate: self)
95
+ }
96
+
97
+ private func resumeStoredSession() {
98
+ guard let stored = UserDefaults.standard.string(forKey: "SpotnySDK_userUUID") else { return }
99
+ currentUserUUID = stored
100
+ userId = UserDefaults.standard.object(forKey: "SpotnySDK_userId") as? Int
101
+ print("🔄 SpotnySDK: Resuming session for UUID: \(stored)")
102
+ startPersistentScanning()
103
+ scanning = true
104
+ }
105
+
106
+ // MARK: - ObjC-Exposed Methods (called from SpotnySdk.mm)
107
+
108
+ @objc
109
+ public func startScanner(
110
+ withUserUUID userUUID: String,
111
+ userId: NSNumber?,
112
+ resolve: @escaping RCTPromiseResolveBlock,
113
+ reject: @escaping RCTPromiseRejectBlock
114
+ ) {
115
+ if scanning {
116
+ resolve("Already scanning")
117
+ return
118
+ }
119
+
120
+ currentUserUUID = userUUID
121
+ self.userId = userId?.intValue
122
+
123
+ UserDefaults.standard.set(userUUID, forKey: "SpotnySDK_userUUID")
124
+ if let uid = userId {
125
+ UserDefaults.standard.set(uid.intValue, forKey: "SpotnySDK_userId")
126
+ } else {
127
+ UserDefaults.standard.removeObject(forKey: "SpotnySDK_userId")
128
+ }
129
+ UserDefaults.standard.synchronize()
130
+
131
+ let status = locationManager.authorizationStatus
132
+ if status == .notDetermined {
133
+ locationManager.requestAlwaysAuthorization()
134
+ }
135
+
136
+ startPersistentScanning()
137
+ scanning = true
138
+ print("✅ SpotnySDK: Started scanning for \(userUUID)")
139
+ resolve("Scanning started")
140
+ }
141
+
142
+ @objc
143
+ public func stopScanner(
144
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
145
+ reject: @escaping RCTPromiseRejectBlock
146
+ ) {
147
+ beaconManager.stopMonitoringForAllRegions()
148
+ beaconManager.stopRangingBeaconsInAllRegions()
149
+ cleanupAllProximityState()
150
+
151
+ UserDefaults.standard.removeObject(forKey: "SpotnySDK_userUUID")
152
+ UserDefaults.standard.removeObject(forKey: "SpotnySDK_userId")
153
+ UserDefaults.standard.synchronize()
154
+
155
+ scanning = false
156
+ currentUserUUID = nil
157
+ userId = nil
158
+
159
+ print("⏹️ SpotnySDK: Stopped scanning")
160
+ resolve("Scanning stopped")
161
+ }
162
+
163
+ @objc
164
+ public func isScanning(
165
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
166
+ reject: @escaping RCTPromiseRejectBlock
167
+ ) {
168
+ resolve(scanning)
169
+ }
170
+
171
+ @objc
172
+ public func configure(
173
+ with config: NSDictionary,
174
+ resolve: @escaping RCTPromiseResolveBlock,
175
+ reject: @escaping RCTPromiseRejectBlock
176
+ ) {
177
+ if let url = config["backendURL"] as? String {
178
+ backendURL = url
179
+ print("⚙️ SpotnySDK: backendURL = \(url)")
180
+ }
181
+ if let dist = config["maxDetectionDistance"] as? Double {
182
+ maxDetectionDistance = dist
183
+ print("⚙️ SpotnySDK: maxDetectionDistance = \(dist)m")
184
+ }
185
+ if let key = config["kontaktAPIKey"] as? String {
186
+ kontaktAPIKey = key
187
+ Kontakt.setAPIKey(key)
188
+ print("⚙️ SpotnySDK: Kontakt.io API key updated")
189
+ }
190
+ resolve("Configuration updated")
191
+ }
192
+
193
+ @objc
194
+ public func requestNotificationPermissions(
195
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
196
+ reject: @escaping RCTPromiseRejectBlock
197
+ ) {
198
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
199
+ DispatchQueue.main.async {
200
+ if let error = error {
201
+ reject("PERMISSION_ERROR", error.localizedDescription, error)
202
+ } else {
203
+ resolve(granted ? "granted" : "denied")
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ @objc
210
+ public func setDebounceInterval(
211
+ _ interval: Double,
212
+ resolve: @escaping RCTPromiseResolveBlock,
213
+ reject: @escaping RCTPromiseRejectBlock
214
+ ) {
215
+ debounceInterval = interval
216
+ resolve("Debounce interval set to \(interval)s")
217
+ }
218
+
219
+ @objc
220
+ public func clearDebounceCache(
221
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
222
+ reject: @escaping RCTPromiseRejectBlock
223
+ ) {
224
+ lastCampaignFetchAttempt.removeAll()
225
+ fetchInProgress.removeAll()
226
+ resolve("Debounce cache cleared")
227
+ }
228
+
229
+ @objc
230
+ public func getDebounceStatus(
231
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
232
+ reject: @escaping RCTPromiseRejectBlock
233
+ ) {
234
+ var status: [String: Any] = [:]
235
+ for (key, _) in activeCampaigns {
236
+ var entry: [String: Any] = [:]
237
+ if let lastFetch = lastCampaignFetchAttempt[key] {
238
+ entry["lastFetchAttempt"] = lastFetch.timeIntervalSince1970
239
+ }
240
+ if let inProg = fetchInProgress[key] {
241
+ entry["fetchInProgress"] = inProg
242
+ }
243
+ if let lastProx = lastProximityEventSent[key] {
244
+ entry["lastProximityEvent"] = lastProx.timeIntervalSince1970
245
+ }
246
+ if let lastImp = lastImpressionEventSent[key] {
247
+ entry["lastImpressionEvent"] = lastImp.timeIntervalSince1970
248
+ }
249
+ status[key] = entry
250
+ }
251
+ resolve(status)
252
+ }
253
+
254
+ // MARK: - Debug Logging
255
+
256
+ @objc
257
+ public func getDebugLogs(
258
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
259
+ reject: @escaping RCTPromiseRejectBlock
260
+ ) {
261
+ guard let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
262
+ reject("ERROR", "Cannot access documents directory", nil); return
263
+ }
264
+ let logURL = docsURL.appendingPathComponent("spotny_beacon_debug.log")
265
+ resolve((try? String(contentsOf: logURL, encoding: .utf8)) ?? "No logs found")
266
+ }
267
+
268
+ @objc
269
+ public func clearDebugLogs(
270
+ withResolve resolve: @escaping RCTPromiseResolveBlock,
271
+ reject: @escaping RCTPromiseRejectBlock
272
+ ) {
273
+ guard let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
274
+ reject("ERROR", "Cannot access documents directory", nil); return
275
+ }
276
+ try? FileManager.default.removeItem(at: docsURL.appendingPathComponent("spotny_beacon_debug.log"))
277
+ resolve("Logs cleared")
278
+ }
279
+
280
+ // MARK: - Scanning Setup
281
+
282
+ private func startPersistentScanning() {
283
+ print("🚀 SpotnySDK: Starting persistent beacon scanning…")
284
+
285
+ // General region — monitors all beacons with the Kontakt.io UUID
286
+ let generalRegion = KTKBeaconRegion(proximityUUID: beaconUUID, identifier: "SpotnySDK_GeneralRegion")
287
+ generalRegion.notifyOnEntry = true
288
+ generalRegion.notifyOnExit = true
289
+ generalRegion.notifyEntryStateOnDisplay = true
290
+
291
+ beaconManager.startMonitoring(for: generalRegion)
292
+ beaconManager.startRangingBeacons(in: generalRegion)
293
+
294
+ print("🎯 SpotnySDK: Monitoring UUID \(beaconUUID.uuidString)")
295
+ }
296
+
297
+ // MARK: - Helpers
298
+
299
+ private func beaconKey(major: Int, minor: Int) -> String { "\(major)_\(minor)" }
300
+
301
+ private func getDeviceId() -> String {
302
+ if let stored = UserDefaults.standard.string(forKey: "SpotnySDK_deviceId") {
303
+ return stored
304
+ }
305
+ let id = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
306
+ UserDefaults.standard.set(id, forKey: "SpotnySDK_deviceId")
307
+ UserDefaults.standard.synchronize()
308
+ return id
309
+ }
310
+
311
+ private func proximityLabel(from distance: Double) -> String {
312
+ if distance < 0 { return "unknown" }
313
+ if distance < 0.5 { return "immediate" }
314
+ if distance < 3.0 { return "near" }
315
+ return "far"
316
+ }
317
+
318
+ private func logToFile(_ message: String) {
319
+ guard let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
320
+ let logURL = docsURL.appendingPathComponent("spotny_beacon_debug.log")
321
+ let ts = DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .medium)
322
+ let entry = "[\(ts)] \(message)\n"
323
+ guard let data = entry.data(using: .utf8) else { return }
324
+ if FileManager.default.fileExists(atPath: logURL.path),
325
+ let handle = try? FileHandle(forWritingTo: logURL) {
326
+ handle.seekToEndOfFile(); handle.write(data); handle.closeFile()
327
+ } else {
328
+ try? data.write(to: logURL)
329
+ }
330
+ }
331
+
332
+ // MARK: - Backend API
333
+
334
+ private func post(
335
+ endpoint: String,
336
+ payload: [String: Any],
337
+ completion: @escaping (Result<(Int, Data), Error>) -> Void
338
+ ) {
339
+ guard let url = URL(string: "\(backendURL)\(endpoint)") else {
340
+ completion(.failure(NSError(domain: "SpotnySDK", code: -1,
341
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(backendURL)\(endpoint)"])))
342
+ return
343
+ }
344
+ var req = URLRequest(url: url)
345
+ req.httpMethod = "POST"
346
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
347
+ req.timeoutInterval = 10.0
348
+
349
+ var bg: UIBackgroundTaskIdentifier = .invalid
350
+ bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
351
+
352
+ do {
353
+ req.httpBody = try JSONSerialization.data(withJSONObject: payload)
354
+ } catch {
355
+ UIApplication.shared.endBackgroundTask(bg)
356
+ completion(.failure(error)); return
357
+ }
358
+
359
+ URLSession.shared.dataTask(with: req) { data, response, error in
360
+ DispatchQueue.main.async {
361
+ if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
362
+ if let error = error { completion(.failure(error)); return }
363
+ guard let http = response as? HTTPURLResponse, let data = data else {
364
+ completion(.failure(NSError(domain: "SpotnySDK", code: -2,
365
+ userInfo: [NSLocalizedDescriptionKey: "Bad response"]))); return
366
+ }
367
+ completion(.success((http.statusCode, data)))
368
+ }
369
+ }.resume()
370
+ }
371
+
372
+ // MARK: - Campaign Fetching
373
+
374
+ private func fetchCampaign(major: Int, minor: Int, deviceId: String) {
375
+ let key = beaconKey(major: major, minor: minor)
376
+ guard activeCampaigns[key] == nil else { return }
377
+ guard fetchInProgress[key] != true else { return }
378
+
379
+ if let last = lastCampaignFetchAttempt[key],
380
+ Date().timeIntervalSince(last) < campaignFetchCooldown { return }
381
+
382
+ lastCampaignFetchAttempt[key] = Date()
383
+ fetchInProgress[key] = true
384
+
385
+ var payload: [String: Any] = ["beacon_id": key, "device_id": deviceId]
386
+ if let uid = userId { payload["user_id"] = uid }
387
+
388
+ post(endpoint: "/api/app/campaigns/beacon", payload: payload) { [weak self] result in
389
+ guard let self = self else { return }
390
+ defer { self.fetchInProgress[key] = false }
391
+
392
+ guard case .success(let (status, data)) = result, status == 200 else {
393
+ if case .success(let (s, _)) = result {
394
+ print("❌ SpotnySDK: Campaign fetch status \(s) for beacon \(key)")
395
+ }
396
+ return
397
+ }
398
+ do {
399
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
400
+ let dataObj = json["data"] as? [String: Any],
401
+ let screen = dataObj["screen"] as? [String: Any],
402
+ let screenId = screen["id"] as? Int else {
403
+ print("⚠️ SpotnySDK: Unexpected campaign response format for \(key)")
404
+ return
405
+ }
406
+ var campaignId: Int?
407
+ var inQueue = false
408
+ if let campaignObj = dataObj["campaign"] as? [String: Any],
409
+ let cid = campaignObj["id"] as? Int {
410
+ campaignId = cid
411
+ inQueue = campaignObj["inQueue"] as? Bool ?? false
412
+ }
413
+ self.activeCampaigns[key] = CampaignData(
414
+ campaignId: campaignId, screenId: screenId,
415
+ sessionId: nil, inQueue: inQueue, major: major, minor: minor)
416
+ print("✅ SpotnySDK: Campaign loaded for beacon \(key) — screenId=\(screenId)")
417
+ } catch {
418
+ print("❌ SpotnySDK: JSON parse error for beacon \(key): \(error)")
419
+ }
420
+ }
421
+ }
422
+
423
+ // MARK: - Tracking
424
+
425
+ private func sendTracking(
426
+ eventType: String,
427
+ key: String,
428
+ distance: Double,
429
+ deviceId: String,
430
+ endpoint: String
431
+ ) {
432
+ let isImpression = eventType == "IMPRESSION_HEARTBEAT"
433
+ let inProg = isImpression ? impressionEventInProgress[key] : proximityEventInProgress[key]
434
+ guard inProg != true else { return }
435
+
436
+ if isImpression { impressionEventInProgress[key] = true }
437
+ else { proximityEventInProgress[key] = true }
438
+
439
+ guard let campaign = activeCampaigns[key] else {
440
+ if isImpression { impressionEventInProgress[key] = false }
441
+ else { proximityEventInProgress[key] = false }
442
+ return
443
+ }
444
+ if isImpression {
445
+ guard let _ = campaign.campaignId, !campaign.inQueue else {
446
+ impressionEventInProgress[key] = false; return
447
+ }
448
+ }
449
+
450
+ var payload: [String: Any] = [
451
+ "event_type": eventType,
452
+ "device_id": deviceId,
453
+ "distance": distance,
454
+ "screen_id": campaign.screenId
455
+ ]
456
+ if let cid = campaign.campaignId { payload["campaign_id"] = cid }
457
+ if let sid = campaign.sessionId { payload["session_id"] = sid }
458
+ if let uid = userId { payload["user_id"] = uid }
459
+
460
+ post(endpoint: endpoint, payload: payload) { [weak self] result in
461
+ guard let self = self else { return }
462
+ switch result {
463
+ case .success(let (status, data)):
464
+ if 200...299 ~= status {
465
+ print("✅ SpotnySDK: \(eventType) sent — distance \(String(format: "%.2f", distance))m")
466
+ if !isImpression, campaign.sessionId == nil,
467
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
468
+ let dObj = json["data"] as? [String: Any],
469
+ let ev = dObj["event"] as? [String: Any],
470
+ let sid = ev["session_id"] as? String {
471
+ let updated = CampaignData(
472
+ campaignId: campaign.campaignId, screenId: campaign.screenId,
473
+ sessionId: sid, inQueue: campaign.inQueue,
474
+ major: campaign.major, minor: campaign.minor)
475
+ self.activeCampaigns[key] = updated
476
+ print("✅ SpotnySDK: session_id = \(sid)")
477
+ }
478
+ } else if status == 429 {
479
+ let penalty = Date().addingTimeInterval(10)
480
+ if isImpression { self.lastImpressionEventSent[key] = penalty }
481
+ else { self.lastProximityEventSent[key] = penalty }
482
+ print("⚠️ SpotnySDK: \(eventType) rate-limited (429)")
483
+ } else {
484
+ print("❌ SpotnySDK: \(eventType) failed — status \(status)")
485
+ }
486
+ case .failure(let error):
487
+ print("❌ SpotnySDK: \(eventType) error — \(error.localizedDescription)")
488
+ }
489
+ if isImpression { self.impressionEventInProgress[key] = false }
490
+ else { self.proximityEventInProgress[key] = false }
491
+ }
492
+ }
493
+
494
+ private func sendProximity(eventType: String, key: String, distance: Double, deviceId: String) {
495
+ sendTracking(eventType: eventType, key: key, distance: distance,
496
+ deviceId: deviceId, endpoint: "/api/app/impressions/proximity")
497
+ }
498
+
499
+ private func sendImpression(key: String, distance: Double, deviceId: String) {
500
+ sendTracking(eventType: "IMPRESSION_HEARTBEAT", key: key, distance: distance,
501
+ deviceId: deviceId, endpoint: "/api/app/impressions/track")
502
+ }
503
+
504
+ // MARK: - State Cleanup
505
+
506
+ private func cleanupBeacon(_ key: String, deviceId: String, distance: Double = 0) {
507
+ sendProximity(eventType: "PROXIMITY_EXIT", key: key, distance: distance, deviceId: deviceId)
508
+ activeCampaigns.removeValue(forKey: key)
509
+ lastProximityEventSent.removeValue(forKey: key)
510
+ lastProximityDistance.removeValue(forKey: key)
511
+ lastImpressionEventSent.removeValue(forKey: key)
512
+ lastCampaignFetchAttempt.removeValue(forKey: key)
513
+ fetchInProgress.removeValue(forKey: key)
514
+ proximityEventInProgress.removeValue(forKey: key)
515
+ impressionEventInProgress.removeValue(forKey: key)
516
+ print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
517
+ }
518
+
519
+ private func cleanupAllProximityState() {
520
+ let deviceId = getDeviceId()
521
+ for key in activeCampaigns.keys { cleanupBeacon(key, deviceId: deviceId) }
522
+ }
523
+ }
524
+
525
+ // MARK: - CLLocationManagerDelegate
526
+
527
+ extension SpotnyBeaconScanner: CLLocationManagerDelegate {
528
+ public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
529
+ switch status {
530
+ case .authorizedAlways: print("✅ SpotnySDK: Location → ALWAYS")
531
+ case .authorizedWhenInUse: print("⚠️ SpotnySDK: Location → WHEN IN USE (limited background)")
532
+ case .denied, .restricted: print("❌ SpotnySDK: Location → DENIED")
533
+ case .notDetermined: print("⏳ SpotnySDK: Location → not determined")
534
+ @unknown default: break
535
+ }
536
+ }
537
+ }
538
+
539
+ // MARK: - KTKBeaconManagerDelegate
540
+
541
+ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
542
+
543
+ public func beaconManager(
544
+ _ manager: KTKBeaconManager,
545
+ didRangeBeacons beacons: [CLBeacon],
546
+ in region: KTKBeaconRegion
547
+ ) {
548
+ let deviceId = getDeviceId()
549
+ let now = Date()
550
+
551
+ // Build the JS event payload for ALL ranged beacons
552
+ let beaconPayload: [[String: Any]] = beacons.compactMap { beacon in
553
+ let raw = beacon.accuracy
554
+ let adjusted = raw * 0.5 // compensate for low TX power (-12 dBm)
555
+ guard adjusted > 0 && adjusted <= maxDetectionDistance else { return nil }
556
+ return [
557
+ "uuid": beacon.proximityUUID.uuidString,
558
+ "major": beacon.major.intValue,
559
+ "minor": beacon.minor.intValue,
560
+ "distance": adjusted,
561
+ "rssi": beacon.rssi,
562
+ "proximity": proximityLabel(from: adjusted)
563
+ ]
564
+ }
565
+
566
+ if !beaconPayload.isEmpty {
567
+ eventCallback?("onBeaconsRanged", ["beacons": beaconPayload, "region": region.identifier])
568
+ }
569
+
570
+ // Per-beacon campaign & proximity logic
571
+ for beacon in beacons {
572
+ let major = beacon.major.intValue
573
+ let minor = beacon.minor.intValue
574
+ let key = beaconKey(major: major, minor: minor)
575
+ let raw = beacon.accuracy
576
+ let distance = raw * 0.5
577
+
578
+ guard distance > 0 && distance <= maxDetectionDistance else { continue }
579
+
580
+ if let campaign = activeCampaigns[key] {
581
+ let isFirst = lastProximityEventSent[key] == nil
582
+
583
+ if isFirst {
584
+ sendProximity(eventType: "NEARBY", key: key, distance: distance, deviceId: deviceId)
585
+ lastProximityDistance[key] = distance
586
+ lastProximityEventSent[key] = now
587
+ } else if distance >= 1.0,
588
+ let lastDist = lastProximityDistance[key],
589
+ abs(distance - lastDist) >= proximityDistanceThreshold {
590
+ sendProximity(eventType: "NEARBY", key: key, distance: distance, deviceId: deviceId)
591
+ lastProximityDistance[key] = distance
592
+ lastProximityEventSent[key] = now
593
+ }
594
+
595
+ // Impression heartbeat when user is very close
596
+ if let _ = campaign.campaignId, !campaign.inQueue, distance <= impressionDistance {
597
+ if let last = lastImpressionEventSent[key] {
598
+ if now.timeIntervalSince(last) >= impressionEventInterval {
599
+ sendImpression(key: key, distance: distance, deviceId: deviceId)
600
+ lastImpressionEventSent[key] = now
601
+ }
602
+ } else {
603
+ sendImpression(key: key, distance: distance, deviceId: deviceId)
604
+ lastImpressionEventSent[key] = now
605
+ }
606
+ }
607
+ } else {
608
+ fetchCampaign(major: major, minor: minor, deviceId: deviceId)
609
+ }
610
+ }
611
+ }
612
+
613
+ public func beaconManager(_ manager: KTKBeaconManager, didEnter region: KTKBeaconRegion) {
614
+ print("🎯 SpotnySDK: Entered region \(region.identifier)")
615
+
616
+ eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "enter"])
617
+
618
+ var bg: UIBackgroundTaskIdentifier = .invalid
619
+ bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
620
+
621
+ beaconManager.startRangingBeacons(in: region)
622
+
623
+ // Parse major/minor from named regions (e.g. "SpotnySDK_52885_35127")
624
+ let parts = region.identifier.components(separatedBy: "_")
625
+ if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
626
+ let key = beaconKey(major: major, minor: minor)
627
+ lastCampaignFetchAttempt.removeValue(forKey: key)
628
+ if activeCampaigns[key] == nil {
629
+ fetchCampaign(major: major, minor: minor, deviceId: getDeviceId())
630
+ }
631
+ }
632
+
633
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
634
+ if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
635
+ }
636
+ }
637
+
638
+ public func beaconManager(_ manager: KTKBeaconManager, didExitRegion region: KTKBeaconRegion) {
639
+ print("🚪 SpotnySDK: Exited region \(region.identifier)")
640
+
641
+ eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "exit"])
642
+
643
+ var bg: UIBackgroundTaskIdentifier = .invalid
644
+ bg = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
645
+
646
+ let parts = region.identifier.components(separatedBy: "_")
647
+ if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
648
+ cleanupBeacon(beaconKey(major: major, minor: minor), deviceId: getDeviceId())
649
+ }
650
+
651
+ beaconManager.stopRangingBeacons(in: region)
652
+
653
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
654
+ if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
655
+ }
656
+ }
657
+
658
+ public func beaconManager(
659
+ _ manager: KTKBeaconManager,
660
+ didDetermineState state: CLRegionState,
661
+ for region: KTKBeaconRegion
662
+ ) {
663
+ let label: String
664
+ switch state {
665
+ case .inside: label = "inside"
666
+ case .outside: label = "outside"
667
+ case .unknown: label = "unknown"
668
+ @unknown default: label = "unknown"
669
+ }
670
+ eventCallback?("onBeaconRegionEvent", ["region": region.identifier, "event": "determined", "state": label])
671
+ print("📊 SpotnySDK: Region \(region.identifier) → \(label)")
672
+ }
673
+
674
+ public func beaconManager(
675
+ _ manager: KTKBeaconManager,
676
+ monitoringDidFailFor region: KTKBeaconRegion?,
677
+ withError error: Error?
678
+ ) {
679
+ print("❌ SpotnySDK: Monitoring failed for \(region?.identifier ?? "?") — \(error?.localizedDescription ?? "unknown error")")
680
+ }
681
+ }
@@ -0,0 +1,8 @@
1
+ //
2
+ // SpotnySdk-Bridging-Header.h
3
+ // Makes React Native and Kontakt.io SDK types visible to Swift.
4
+ //
5
+
6
+ #import <React/RCTBridgeModule.h>
7
+ #import <React/RCTEventEmitter.h>
8
+ #import <KontaktSDK/KontaktSDK.h>
@@ -0,0 +1,10 @@
1
+ #import <SpotnySdkSpec/SpotnySdkSpec.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ /**
5
+ * SpotnySdk is both a Turbo Module (NativeSpotnySdkSpec) and an RCTEventEmitter
6
+ * so it can push beacon events to JavaScript via NativeEventEmitter.
7
+ */
8
+ @interface SpotnySdk : RCTEventEmitter <NativeSpotnySdkSpec>
9
+
10
+ @end