spotny-sdk 0.3.8 → 0.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # spotny-sdk
2
2
 
3
- A React Native SDK for iBeacon proximity detection. Detects nearby beacons, fires region enter/exit events, and powers real-time proximity experiences on iOS and Android.
3
+ A React Native SDK for real-time proximity experiences. Detects nearby points of interest and fires location events on iOS and Android.
4
4
 
5
5
  > Requires **React Native 0.73+** with the New Architecture (Turbo Modules) enabled.
6
6
 
@@ -24,11 +24,11 @@ Add the following keys to your `Info.plist`:
24
24
 
25
25
  ```xml
26
26
  <key>NSLocationWhenInUseUsageDescription</key>
27
- <string>We use your location to detect nearby beacons.</string>
27
+ <string>We use your location to detect nearby points of interest.</string>
28
28
  <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
29
- <string>We use your location in the background to detect nearby beacons.</string>
29
+ <string>We use your location in the background to detect nearby points of interest.</string>
30
30
  <key>NSBluetoothAlwaysUsageDescription</key>
31
- <string>We use Bluetooth to scan for nearby beacons.</string>
31
+ <string>We use Bluetooth to detect nearby points of interest.</string>
32
32
  ```
33
33
 
34
34
  Enable **Background Modes** in Xcode → your app target → **Signing & Capabilities** → **+ Capability** → Background Modes:
@@ -36,7 +36,7 @@ Enable **Background Modes** in Xcode → your app target → **Signing & Capabil
36
36
  - ✅ Location updates
37
37
  - ✅ Uses Bluetooth LE accessories
38
38
 
39
- > **Important — avoid crash on launch:** If your app does **not** enable the _Location updates_ background mode, the SDK will still work for foreground-only scanning. However, if you enable it in Xcode, the `UIBackgroundModes: [location]` entry is added to `Info.plist` and the SDK will automatically enable background scanning. Omitting this while calling `startScanner` is safe — the SDK detects the missing capability at runtime and skips the background-location setting. Earlier versions (< 0.3.4) would crash with `NSInternalInconsistencyException` if the background mode was not declared; this is fixed in **0.3.4+**.
39
+ > **Note:** Background scanning requires the _Location updates_ background mode. The SDK works without it for foreground-only use.
40
40
 
41
41
  ### Android
42
42
 
@@ -60,32 +60,28 @@ import {
60
60
  configure,
61
61
  startScanner,
62
62
  stopScanner,
63
- addBeaconsRangedListener,
64
- addBeaconRegionListener,
63
+ addProximityListener,
64
+ addLocationEventListener,
65
65
  } from 'spotny-sdk';
66
66
 
67
67
  export default function App() {
68
68
  useEffect(() => {
69
- // Optional: override defaults before starting
70
- configure({ maxDetectionDistance: 10 });
71
-
72
- // Start scanning with a unique device/user identifier
69
+ configure({ source: 'nike', maxDetectionDistance: 10 });
73
70
  startScanner('device-uuid-here', /* userId */ null);
74
71
 
75
- // Listen for ranged beacons (fires ~every second)
76
- const rangedSub = addBeaconsRangedListener(({ beacons, region }) => {
77
- console.log('Beacons in range:', beacons);
72
+ const proxSub = addProximityListener((items) => {
73
+ console.log('Nearby:', items);
78
74
  });
79
75
 
80
- // Listen for region enter / exit
81
- const regionSub = addBeaconRegionListener(({ region, event }) => {
82
- console.log(`Region ${region}: ${event}`);
76
+ const locationSub = addLocationEventListener(({ event, zone }) => {
77
+ if (event === 'enter') console.log('Entered zone', zone);
78
+ if (event === 'exit') console.log('Left zone', zone);
83
79
  });
84
80
 
85
81
  return () => {
86
82
  stopScanner();
87
- rangedSub.remove();
88
- regionSub.remove();
83
+ proxSub.remove();
84
+ locationSub.remove();
89
85
  };
90
86
  }, []);
91
87
 
@@ -97,18 +93,18 @@ export default function App() {
97
93
 
98
94
  ## API Reference
99
95
 
100
- ### Core Scanning
96
+ ### Core
101
97
 
102
98
  #### `startScanner(userUUID, userId?)`
103
99
 
104
- Starts BLE beacon scanning and begins ranging / monitoring regions.
100
+ Starts proximity scanning.
105
101
 
106
- | Parameter | Type | Required | Description |
107
- | ---------- | ---------------- | -------- | ----------------------------------------------- |
108
- | `userUUID` | `string` | Yes | Unique identifier for this installation/session |
109
- | `userId` | `number \| null` | No | Authenticated user ID |
102
+ | Parameter | Type | Required | Description |
103
+ | ---------- | ---------------- | -------- | ----------------------------------------- |
104
+ | `userUUID` | `string` | Yes | Unique identifier for this device/session |
105
+ | `userId` | `number \| null` | No | Authenticated user ID |
110
106
 
111
- Returns `Promise<string>` — resolves with a status message.
107
+ Returns `Promise<string>`.
112
108
 
113
109
  ```ts
114
110
  await startScanner('550e8400-e29b-41d4-a716-446655440000', 42);
@@ -118,23 +114,15 @@ await startScanner('550e8400-e29b-41d4-a716-446655440000', 42);
118
114
 
119
115
  #### `stopScanner()`
120
116
 
121
- Stops scanning, halts ranging, and clears all in-memory beacon state.
117
+ Stops scanning and clears all state.
122
118
 
123
119
  Returns `Promise<string>`.
124
120
 
125
- ```ts
126
- await stopScanner();
127
- ```
128
-
129
121
  ---
130
122
 
131
123
  #### `isScanning()`
132
124
 
133
- Returns `Promise<boolean>` — `true` if the SDK is currently scanning.
134
-
135
- ```ts
136
- const scanning = await isScanning();
137
- ```
125
+ Returns `Promise<boolean>`.
138
126
 
139
127
  ---
140
128
 
@@ -142,17 +130,15 @@ const scanning = await isScanning();
142
130
 
143
131
  #### `configure(config)`
144
132
 
145
- Overrides SDK defaults. **Must be called before `startScanner`.**
146
-
147
- | Option | Type | Default | Description |
148
- | ---------------------- | -------- | ------- | ------------------------------------------------------------------------------- |
149
- | `maxDetectionDistance` | `number` | `8.0` | Maximum beacon detection radius (metres) |
150
- | `source` | `string` | – | Identifier for your brand or app (e.g. `'nike'`). |
133
+ Call **before** `startScanner`.
151
134
 
152
- Returns `Promise<string>`.
135
+ | Option | Type | Default | Description |
136
+ | ---------------------- | -------- | ------- | -------------------------------------------- |
137
+ | `source` | `string` | – | Your brand or app identifier (e.g. `'nike'`) |
138
+ | `maxDetectionDistance` | `number` | `8.0` | Detection radius in metres |
153
139
 
154
140
  ```ts
155
- await configure({ maxDetectionDistance: 12, source: 'nike' });
141
+ await configure({ source: 'nike', maxDetectionDistance: 10 });
156
142
  ```
157
143
 
158
144
  ---
@@ -161,139 +147,66 @@ await configure({ maxDetectionDistance: 12, source: 'nike' });
161
147
 
162
148
  #### `requestNotificationPermissions()` _(iOS only)_
163
149
 
164
- Prompts the user for local-notification permissions. Safe to call on Android (no-op).
150
+ Requests local notification permissions.
165
151
 
166
152
  Returns `Promise<string>`.
167
153
 
168
- ```ts
169
- await requestNotificationPermissions();
170
- ```
171
-
172
154
  ---
173
155
 
174
156
  ### Event Listeners
175
157
 
176
158
  #### `addBeaconsRangedListener(callback)`
177
159
 
178
- Fires approximately every second while beacons are within `maxDetectionDistance`.
160
+ Fires when nearby items are detected (approximately every second).
179
161
 
180
162
  ```ts
181
- const subscription = addBeaconsRangedListener(({ beacons, region }) => {
182
- beacons.forEach((b) => {
183
- console.log(`${b.uuid} — ${b.distance.toFixed(1)} m (${b.proximity})`);
163
+ const sub = addBeaconsRangedListener(({ beacons }) => {
164
+ beacons.forEach((item) => {
165
+ console.log(`${item.distance.toFixed(1)} m ${item.proximity}`);
184
166
  });
185
167
  });
186
168
 
187
- // When done:
188
- subscription.remove();
169
+ sub.remove(); // when done
189
170
  ```
190
171
 
191
- **Callback payload — `BeaconRangedEvent`:**
192
-
193
- | Field | Type | Description |
194
- | --------- | -------------- | ------------------------ |
195
- | `beacons` | `BeaconData[]` | Array of visible beacons |
196
- | `region` | `string` | Beacon region identifier |
197
-
198
- **`BeaconData`:**
172
+ Each item contains:
199
173
 
200
174
  | Field | Type | Description |
201
175
  | ----------- | -------- | --------------------------------------------------- |
202
- | `uuid` | `string` | Proximity UUID |
203
- | `major` | `number` | Major value |
204
- | `minor` | `number` | Minor value |
205
176
  | `distance` | `number` | Estimated distance in metres |
206
- | `rssi` | `number` | Signal strength (dBm) |
207
177
  | `proximity` | `string` | `'immediate'` \| `'near'` \| `'far'` \| `'unknown'` |
208
178
 
209
179
  ---
210
180
 
211
181
  #### `addBeaconRegionListener(callback)`
212
182
 
213
- Fires on region enter, exit, and state-determination events. Works in **foreground, background, and terminated state** (iOS).
183
+ Fires when the user enters or exits a zone. Works in foreground, background, and terminated state (iOS).
214
184
 
215
185
  ```ts
216
- const subscription = addBeaconRegionListener(({ region, event, state }) => {
217
- if (event === 'enter') console.log('Entered', region);
218
- if (event === 'exit') console.log('Exited', region);
186
+ const sub = addBeaconRegionListener(({ event, region, state }) => {
187
+ console.log(event, region); // 'enter' | 'exit' | 'determined'
219
188
  });
220
189
 
221
- // When done:
222
- subscription.remove();
190
+ sub.remove(); // when done
223
191
  ```
224
192
 
225
- **Callback payload — `BeaconRegionEvent`:**
226
-
227
193
  | Field | Type | Description |
228
194
  | -------- | -------- | ---------------------------------------------------------- |
229
- | `region` | `string` | Region identifier |
230
195
  | `event` | `string` | `'enter'` \| `'exit'` \| `'determined'` |
196
+ | `region` | `string` | Zone identifier |
231
197
  | `state` | `string` | `'inside'` \| `'outside'` \| `'unknown'` (determined only) |
232
198
 
233
199
  ---
234
200
 
235
- ### Debounce Helpers
236
-
237
- These let you tune how often repeated proximity events are emitted for the same beacon.
238
-
239
- #### `setDebounceInterval(seconds)`
240
-
241
- Sets the minimum gap (in seconds) between proximity events for the same beacon.
242
-
243
- ```ts
244
- await setDebounceInterval(30); // fire at most once per 30 s per beacon
245
- ```
246
-
247
- #### `clearDebounceCache()`
248
-
249
- Resets cooldown state for all beacons, allowing them to fire again immediately.
250
-
251
- ```ts
252
- await clearDebounceCache();
253
- ```
254
-
255
- #### `getDebounceStatus()`
256
-
257
- Returns the current cooldown state for all tracked beacons.
258
-
259
- ```ts
260
- const status = await getDebounceStatus();
261
- console.log(status);
262
- ```
263
-
264
- ---
265
-
266
201
  ### Debug Helpers
267
202
 
268
203
  #### `getDebugLogs()`
269
204
 
270
- Reads the on-device debug log file.
271
-
272
- ```ts
273
- const logs = await getDebugLogs();
274
- console.log(logs);
275
- ```
205
+ Returns on-device debug logs as a string.
276
206
 
277
207
  #### `clearDebugLogs()`
278
208
 
279
- Deletes the on-device debug log file.
280
-
281
- ```ts
282
- await clearDebugLogs();
283
- ```
284
-
285
- ---
286
-
287
- ## TypeScript Types
288
-
289
- ```ts
290
- import type {
291
- BeaconData,
292
- BeaconRangedEvent,
293
- BeaconRegionEvent,
294
- SpotnySdkConfig,
295
- } from 'spotny-sdk';
296
- ```
209
+ Clears on-device debug logs.
297
210
 
298
211
  ---
299
212
 
@@ -301,7 +214,7 @@ import type {
301
214
 
302
215
  | Feature | iOS | Android |
303
216
  | ------------------------ | --- | ------- |
304
- | Region monitoring | ✅ | ✅ |
217
+ | Zone monitoring | ✅ | ✅ |
305
218
  | Background scanning | ✅ | ✅ |
306
219
  | Terminated-state wakeup | ✅ | ✅ |
307
220
  | Notification permissions | ✅ | – |
@@ -11,6 +11,7 @@ import Foundation
11
11
  import CoreLocation
12
12
  import KontaktSDK
13
13
  import UserNotifications
14
+ import Security
14
15
 
15
16
  // MARK: - Public ObjC-visible typealias for the event callback block
16
17
 
@@ -69,6 +70,7 @@ public class SpotnyBeaconScanner: NSObject {
69
70
  private var lastImpressionEventSent: [String: Date] = [:]
70
71
  private var lastCampaignFetchAttempt: [String: Date] = [:]
71
72
  private var fetchInProgress: [String: Bool] = [:]
73
+ private var fetchRetryCount: [String: Int] = [:]
72
74
  private var proximityEventInProgress: [String: Bool] = [:]
73
75
  private var impressionEventInProgress: [String: Bool] = [:]
74
76
 
@@ -227,6 +229,7 @@ public class SpotnyBeaconScanner: NSObject {
227
229
  ) {
228
230
  lastCampaignFetchAttempt.removeAll()
229
231
  fetchInProgress.removeAll()
232
+ fetchRetryCount.removeAll()
230
233
  resolve("Debounce cache cleared")
231
234
  }
232
235
 
@@ -302,13 +305,51 @@ public class SpotnyBeaconScanner: NSObject {
302
305
 
303
306
  private func beaconKey(major: Int, minor: Int) -> String { "\(major)_\(minor)" }
304
307
 
308
+ // ── Keychain helpers (device ID survives uninstall/reinstall) ──────────────
309
+ private let keychainService = "app.spotny.sdk"
310
+
311
+ private func keychainRead(key: String) -> String? {
312
+ let query: [CFString: Any] = [
313
+ kSecClass: kSecClassGenericPassword,
314
+ kSecAttrService: keychainService,
315
+ kSecAttrAccount: key,
316
+ kSecReturnData: true,
317
+ kSecMatchLimit: kSecMatchLimitOne
318
+ ]
319
+ var result: AnyObject?
320
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
321
+ let data = result as? Data else { return nil }
322
+ return String(data: data, encoding: .utf8)
323
+ }
324
+
325
+ private func keychainWrite(key: String, value: String) {
326
+ guard let data = value.data(using: .utf8) else { return }
327
+ let query: [CFString: Any] = [
328
+ kSecClass: kSecClassGenericPassword,
329
+ kSecAttrService: keychainService,
330
+ kSecAttrAccount: key
331
+ ]
332
+ if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
333
+ SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
334
+ } else {
335
+ var item = query; item[kSecValueData] = data
336
+ SecItemAdd(item as CFDictionary, nil)
337
+ }
338
+ }
339
+
305
340
  private func getDeviceId() -> String {
306
- if let stored = UserDefaults.standard.string(forKey: "SpotnySDK_deviceId") {
307
- return stored
341
+ let kcKey = "SpotnySDK_deviceId"
342
+ // 1. Keychain — survives uninstall
343
+ if let stored = keychainRead(key: kcKey) { return stored }
344
+ // 2. Migrate existing UserDefaults value on first run after upgrade
345
+ if let legacy = UserDefaults.standard.string(forKey: kcKey) {
346
+ keychainWrite(key: kcKey, value: legacy)
347
+ UserDefaults.standard.removeObject(forKey: kcKey)
348
+ return legacy
308
349
  }
350
+ // 3. Generate new ID
309
351
  let id = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
310
- UserDefaults.standard.set(id, forKey: "SpotnySDK_deviceId")
311
- UserDefaults.standard.synchronize()
352
+ keychainWrite(key: kcKey, value: id)
312
353
  return id
313
354
  }
314
355
 
@@ -375,13 +416,22 @@ public class SpotnyBeaconScanner: NSObject {
375
416
 
376
417
  // MARK: - Campaign Fetching
377
418
 
419
+ /// Exponential cooldown: 5 s → 15 s → 45 s. After 3 failures the key is
420
+ /// considered permanently failed until the beacon region is re-entered.
421
+ private func fetchCooldown(for key: String) -> TimeInterval {
422
+ let retries = fetchRetryCount[key] ?? 0
423
+ return campaignFetchCooldown * pow(3.0, Double(min(retries, 3)))
424
+ }
425
+
378
426
  private func fetchCampaign(major: Int, minor: Int, deviceId: String) {
379
427
  let key = beaconKey(major: major, minor: minor)
380
428
  guard activeCampaigns[key] == nil else { return }
381
429
  guard fetchInProgress[key] != true else { return }
430
+ // Give up after 3 consecutive failures (cooldown would be 135 s+)
431
+ if (fetchRetryCount[key] ?? 0) > 3 { return }
382
432
 
383
433
  if let last = lastCampaignFetchAttempt[key],
384
- Date().timeIntervalSince(last) < campaignFetchCooldown { return }
434
+ Date().timeIntervalSince(last) < fetchCooldown(for: key) { return }
385
435
 
386
436
  lastCampaignFetchAttempt[key] = Date()
387
437
  fetchInProgress[key] = true
@@ -398,6 +448,8 @@ public class SpotnyBeaconScanner: NSObject {
398
448
  if case .success(let (s, _)) = result {
399
449
  print("❌ SpotnySDK: Campaign fetch status \(s) for beacon \(key)")
400
450
  }
451
+ // Increment retry count so next attempt uses a longer cooldown
452
+ self.fetchRetryCount[key] = (self.fetchRetryCount[key] ?? 0) + 1
401
453
  return
402
454
  }
403
455
  do {
@@ -418,6 +470,7 @@ public class SpotnyBeaconScanner: NSObject {
418
470
  self.activeCampaigns[key] = CampaignData(
419
471
  campaignId: campaignId, screenId: screenId,
420
472
  sessionId: nil, inQueue: inQueue, major: major, minor: minor)
473
+ self.fetchRetryCount.removeValue(forKey: key) // reset on success
421
474
  print("✅ SpotnySDK: Campaign loaded for beacon \(key) — screenId=\(screenId)")
422
475
  } catch {
423
476
  print("❌ SpotnySDK: JSON parse error for beacon \(key): \(error)")
@@ -517,6 +570,7 @@ public class SpotnyBeaconScanner: NSObject {
517
570
  lastImpressionEventSent.removeValue(forKey: key)
518
571
  lastCampaignFetchAttempt.removeValue(forKey: key)
519
572
  fetchInProgress.removeValue(forKey: key)
573
+ fetchRetryCount.removeValue(forKey: key)
520
574
  proximityEventInProgress.removeValue(forKey: key)
521
575
  impressionEventInProgress.removeValue(forKey: key)
522
576
  print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
@@ -630,7 +684,9 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
630
684
  let parts = region.identifier.components(separatedBy: "_")
631
685
  if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
632
686
  let key = beaconKey(major: major, minor: minor)
687
+ // Reset fetch state on re-entry so retry backoff starts fresh
633
688
  lastCampaignFetchAttempt.removeValue(forKey: key)
689
+ fetchRetryCount.removeValue(forKey: key)
634
690
  if activeCampaigns[key] == nil {
635
691
  fetchCampaign(major: major, minor: minor, deviceId: getDeviceId())
636
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",