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.
- package/LICENSE +20 -0
- package/README.md +37 -0
- package/SpotnySdk.podspec +29 -0
- package/android/build.gradle +70 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/com/spotnysdk/SpotnySdkModule.kt +565 -0
- package/android/src/main/java/com/spotnysdk/SpotnySdkPackage.kt +31 -0
- package/ios/SpotnyBeaconScanner.swift +681 -0
- package/ios/SpotnySdk-Bridging-Header.h +8 -0
- package/ios/SpotnySdk.h +10 -0
- package/ios/SpotnySdk.mm +120 -0
- package/lib/module/NativeSpotnySdk.js +5 -0
- package/lib/module/NativeSpotnySdk.js.map +1 -0
- package/lib/module/index.js +101 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeSpotnySdk.d.ts +18 -0
- package/lib/typescript/src/NativeSpotnySdk.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +68 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +169 -0
- package/src/NativeSpotnySdk.ts +29 -0
- package/src/index.tsx +141 -0
|
@@ -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
|
+
}
|
package/ios/SpotnySdk.h
ADDED
|
@@ -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
|