spotny-sdk 1.0.3 → 1.0.5
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 +9 -293
- package/android/src/main/java/com/spotnysdk/SpotnySdkModule.kt +92 -94
- package/ios/SpotnyBeaconScanner.swift +94 -141
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,11 +255,11 @@ await setDebounceInterval(10); // emit at most every 10 s
|
|
|
255
255
|
|
|
256
256
|
#### `clearDebounceCache()`
|
|
257
257
|
|
|
258
|
-
Resets internal
|
|
258
|
+
Resets internal timing state. Useful during testing.
|
|
259
259
|
|
|
260
260
|
#### `getDebounceStatus()`
|
|
261
261
|
|
|
262
|
-
Returns internal per-beacon timing state. Useful for debugging
|
|
262
|
+
Returns internal per-beacon timing state. Useful for debugging.
|
|
263
263
|
|
|
264
264
|
Returns `Promise<Object>`.
|
|
265
265
|
|
|
@@ -267,12 +267,14 @@ Returns `Promise<Object>`.
|
|
|
267
267
|
|
|
268
268
|
## How It Works
|
|
269
269
|
|
|
270
|
-
1. `initialize()` sends your `token`, `apiKey`, and `
|
|
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
|
|
273
|
-
4.
|
|
274
|
-
5.
|
|
275
|
-
6.
|
|
272
|
+
3. When a beacon is detected, the SDK immediately sends a `NEARBY` proximity event with the `beacon_id`.
|
|
273
|
+
4. The backend resolves the beacon to a `screen_id` and returns campaign data (`campaign_id`, `session_id`).
|
|
274
|
+
5. The SDK stores this campaign data and uses it for subsequent 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.
|
|
277
|
+
8. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
|
|
276
278
|
|
|
277
279
|
All events are tied to the authenticated `identifierId` supplied during `initialize()` — no additional identity calls are required.
|
|
278
280
|
|
|
@@ -301,289 +303,3 @@ All events are tied to the authenticated `identifierId` supplied during `initial
|
|
|
301
303
|
## License
|
|
302
304
|
|
|
303
305
|
MIT
|
|
304
|
-
|
|
305
|
-
---
|
|
306
|
-
|
|
307
|
-
## Installation
|
|
308
|
-
|
|
309
|
-
```sh
|
|
310
|
-
npm install spotny-sdk
|
|
311
|
-
# or
|
|
312
|
-
yarn add spotny-sdk
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
### iOS
|
|
316
|
-
|
|
317
|
-
```sh
|
|
318
|
-
cd ios && pod install
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
Add the following keys to your `Info.plist`:
|
|
322
|
-
|
|
323
|
-
```xml
|
|
324
|
-
<key>NSLocationWhenInUseUsageDescription</key>
|
|
325
|
-
<string>We use your location to detect nearby points of interest.</string>
|
|
326
|
-
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
|
327
|
-
<string>We use your location in the background to detect nearby points of interest.</string>
|
|
328
|
-
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
329
|
-
<string>We use Bluetooth to detect nearby points of interest.</string>
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Enable **Background Modes** in Xcode → your app target → **Signing & Capabilities** → Background Modes:
|
|
333
|
-
|
|
334
|
-
- ✅ Location updates
|
|
335
|
-
- ✅ Uses Bluetooth LE accessories
|
|
336
|
-
|
|
337
|
-
### Android
|
|
338
|
-
|
|
339
|
-
Add to `AndroidManifest.xml`:
|
|
340
|
-
|
|
341
|
-
```xml
|
|
342
|
-
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
|
343
|
-
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
344
|
-
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
345
|
-
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
346
|
-
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
---
|
|
350
|
-
|
|
351
|
-
## Quick Start
|
|
352
|
-
|
|
353
|
-
```tsx
|
|
354
|
-
import { useEffect } from 'react';
|
|
355
|
-
import {
|
|
356
|
-
initialize,
|
|
357
|
-
startScanner,
|
|
358
|
-
stopScanner,
|
|
359
|
-
addBeaconsRangedListener,
|
|
360
|
-
addBeaconRegionListener,
|
|
361
|
-
} from 'spotny-sdk';
|
|
362
|
-
|
|
363
|
-
export default function App() {
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
// 1. Initialize once before anything else
|
|
366
|
-
initialize({
|
|
367
|
-
token: 'YOUR_SDK_TOKEN', // required — from your Spotny dashboard
|
|
368
|
-
source: 'nike',
|
|
369
|
-
maxDetectionDistance: 10,
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// 2. Listen for nearby beacons
|
|
373
|
-
const beaconSub = addBeaconsRangedListener(({ beacons, region }) => {
|
|
374
|
-
beacons.forEach((b) =>
|
|
375
|
-
console.log(
|
|
376
|
-
`${b.major}/${b.minor} — ${b.distance.toFixed(1)}m (${b.proximity})`
|
|
377
|
-
)
|
|
378
|
-
);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
// 3. Listen for region enter/exit
|
|
382
|
-
const regionSub = addBeaconRegionListener(({ event, region }) => {
|
|
383
|
-
console.log(`${event.toUpperCase()}: ${region}`);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// 4. Start scanning
|
|
387
|
-
startScanner();
|
|
388
|
-
|
|
389
|
-
return () => {
|
|
390
|
-
stopScanner();
|
|
391
|
-
beaconSub.remove();
|
|
392
|
-
regionSub.remove();
|
|
393
|
-
};
|
|
394
|
-
}, []);
|
|
395
|
-
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
---
|
|
401
|
-
|
|
402
|
-
## API Reference
|
|
403
|
-
|
|
404
|
-
### Initialization
|
|
405
|
-
|
|
406
|
-
#### `initialize(config)`
|
|
407
|
-
|
|
408
|
-
**Must be called before any other SDK function.** Configures the SDK with your brand settings.
|
|
409
|
-
|
|
410
|
-
| Option | Type | Default | Description |
|
|
411
|
-
| -------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
412
|
-
| `token` | `string` | **required** | SDK token issued by Spotny for your app. Verified against the backend — rejects with `UNAUTHORIZED` if invalid. |
|
|
413
|
-
| `source` | `string` | – | Your brand or app identifier (e.g. `'nike'`). Sent with every tracking event. |
|
|
414
|
-
| `maxDetectionDistance` | `number` | `8.0` | Maximum detection radius in metres. Beacons beyond this are ignored. |
|
|
415
|
-
| `distanceCorrectionFactor` | `number` | `0.5` | Multiplier applied to raw RSSI distance. Tune for your beacon TX power. |
|
|
416
|
-
|
|
417
|
-
Returns `Promise<string>`. **Rejects** if the token is missing, invalid, or the network request fails — handle the error before calling `startScanner()`.
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
await initialize({
|
|
421
|
-
token: 'YOUR_SDK_TOKEN',
|
|
422
|
-
source: 'nike',
|
|
423
|
-
maxDetectionDistance: 10,
|
|
424
|
-
distanceCorrectionFactor: 0.5,
|
|
425
|
-
});
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
---
|
|
429
|
-
|
|
430
|
-
### Core Scanning
|
|
431
|
-
|
|
432
|
-
#### `startScanner()`
|
|
433
|
-
|
|
434
|
-
Starts iBeacon scanning. The SDK manages a fully anonymous `device_id` internally (stored in Keychain on iOS, SharedPreferences on Android — persists across launches).
|
|
435
|
-
|
|
436
|
-
Returns `Promise<string>`.
|
|
437
|
-
|
|
438
|
-
```ts
|
|
439
|
-
await startScanner();
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
---
|
|
443
|
-
|
|
444
|
-
#### `stopScanner()`
|
|
445
|
-
|
|
446
|
-
Stops scanning and cleans up all per-beacon state (sends `PROXIMITY_EXIT` events for any active beacons).
|
|
447
|
-
|
|
448
|
-
Returns `Promise<string>`.
|
|
449
|
-
|
|
450
|
-
---
|
|
451
|
-
|
|
452
|
-
#### `isScanning()`
|
|
453
|
-
|
|
454
|
-
Returns `Promise<boolean>` — `true` if the scanner is currently active.
|
|
455
|
-
|
|
456
|
-
---
|
|
457
|
-
|
|
458
|
-
### Permissions
|
|
459
|
-
|
|
460
|
-
#### `requestNotificationPermissions()` _(iOS only)_
|
|
461
|
-
|
|
462
|
-
Prompts the user for local notification permission (`alert`, `sound`, `badge`).
|
|
463
|
-
|
|
464
|
-
Returns `Promise<'granted' | 'denied'>`.
|
|
465
|
-
|
|
466
|
-
---
|
|
467
|
-
|
|
468
|
-
### Event Listeners
|
|
469
|
-
|
|
470
|
-
#### `addBeaconsRangedListener(callback)`
|
|
471
|
-
|
|
472
|
-
Fires when the set of nearby beacons changes, or every `debounceInterval` seconds (default 5 s) if nothing changed. The event is **deduplicated** — it does not fire every ranging cycle if proximity is unchanged.
|
|
473
|
-
|
|
474
|
-
```ts
|
|
475
|
-
const sub = addBeaconsRangedListener(({ beacons, region }) => {
|
|
476
|
-
beacons.forEach((b) => {
|
|
477
|
-
console.log(`Major ${b.major} / Minor ${b.minor}`);
|
|
478
|
-
console.log(`Distance: ${b.distance.toFixed(2)}m — ${b.proximity}`);
|
|
479
|
-
console.log(`RSSI: ${b.rssi} dBm`);
|
|
480
|
-
});
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
sub.remove(); // call on cleanup
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
Each beacon in the array:
|
|
487
|
-
|
|
488
|
-
| Field | Type | Description |
|
|
489
|
-
| ----------- | -------- | -------------------------------------------------------------- |
|
|
490
|
-
| `uuid` | `string` | Beacon proximity UUID |
|
|
491
|
-
| `major` | `number` | Beacon major value |
|
|
492
|
-
| `minor` | `number` | Beacon minor value |
|
|
493
|
-
| `distance` | `number` | Estimated distance in metres (after correction factor applied) |
|
|
494
|
-
| `rssi` | `number` | Raw signal strength in dBm |
|
|
495
|
-
| `proximity` | `string` | `'immediate'` \| `'near'` \| `'far'` \| `'unknown'` |
|
|
496
|
-
|
|
497
|
-
---
|
|
498
|
-
|
|
499
|
-
#### `addBeaconRegionListener(callback)`
|
|
500
|
-
|
|
501
|
-
Fires when the user enters or exits a beacon region. Works in foreground, background, and terminated state (iOS).
|
|
502
|
-
|
|
503
|
-
```ts
|
|
504
|
-
const sub = addBeaconRegionListener(({ event, region, state }) => {
|
|
505
|
-
if (event === 'enter') console.log('Entered region', region);
|
|
506
|
-
if (event === 'exit') console.log('Left region', region);
|
|
507
|
-
if (event === 'determined') console.log('State for', region, '→', state);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
sub.remove(); // call on cleanup
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
| Field | Type | Description |
|
|
514
|
-
| -------- | -------- | ------------------------------------------------------------------ |
|
|
515
|
-
| `event` | `string` | `'enter'` \| `'exit'` \| `'determined'` |
|
|
516
|
-
| `region` | `string` | Region identifier |
|
|
517
|
-
| `state` | `string` | `'inside'` \| `'outside'` \| `'unknown'` (`determined` event only) |
|
|
518
|
-
|
|
519
|
-
---
|
|
520
|
-
|
|
521
|
-
### Debug Helpers
|
|
522
|
-
|
|
523
|
-
#### `getDebugLogs()`
|
|
524
|
-
|
|
525
|
-
Returns the on-device debug log file as a string. Includes campaign fetch results and proximity events sent.
|
|
526
|
-
|
|
527
|
-
Returns `Promise<string>`.
|
|
528
|
-
|
|
529
|
-
#### `clearDebugLogs()`
|
|
530
|
-
|
|
531
|
-
Clears the on-device debug log file.
|
|
532
|
-
|
|
533
|
-
Returns `Promise<string>`.
|
|
534
|
-
|
|
535
|
-
#### `setDebounceInterval(seconds)`
|
|
536
|
-
|
|
537
|
-
Sets how often the `onBeaconsRanged` event is force-emitted even if proximity hasn't changed (default: `5`).
|
|
538
|
-
|
|
539
|
-
```ts
|
|
540
|
-
await setDebounceInterval(10); // emit at most every 10 s
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
#### `clearDebounceCache()`
|
|
544
|
-
|
|
545
|
-
Resets internal campaign-fetch cooldown state. Useful during testing.
|
|
546
|
-
|
|
547
|
-
#### `getDebounceStatus()`
|
|
548
|
-
|
|
549
|
-
Returns internal per-beacon timing state. Useful for debugging stuck campaigns.
|
|
550
|
-
|
|
551
|
-
Returns `Promise<Object>`.
|
|
552
|
-
|
|
553
|
-
---
|
|
554
|
-
|
|
555
|
-
## How It Works
|
|
556
|
-
|
|
557
|
-
1. `initialize()` sets your brand config.
|
|
558
|
-
2. `startScanner()` begins monitoring for iBeacons with the Spotny UUID.
|
|
559
|
-
3. When a beacon is detected, the SDK fetches the associated campaign from the Spotny backend.
|
|
560
|
-
4. `NEARBY` events are sent automatically as the user moves closer or further.
|
|
561
|
-
5. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of an active campaign.
|
|
562
|
-
6. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
|
|
563
|
-
|
|
564
|
-
The SDK is **fully anonymous** — it generates a persistent `device_id` (Keychain on iOS, SharedPreferences on Android) that survives app reinstalls. No user identity is collected or sent.
|
|
565
|
-
|
|
566
|
-
---
|
|
567
|
-
|
|
568
|
-
## Platform Support
|
|
569
|
-
|
|
570
|
-
| Feature | iOS | Android |
|
|
571
|
-
| ------------------------ | --- | ------- |
|
|
572
|
-
| iBeacon ranging | ✅ | ✅ |
|
|
573
|
-
| Region enter/exit | ✅ | ✅ |
|
|
574
|
-
| Background scanning | ✅ | ✅ |
|
|
575
|
-
| Terminated-state wakeup | ✅ | ✅ |
|
|
576
|
-
| Keychain device ID | ✅ | – |
|
|
577
|
-
| Notification permissions | ✅ | – |
|
|
578
|
-
|
|
579
|
-
---
|
|
580
|
-
|
|
581
|
-
## Contributing
|
|
582
|
-
|
|
583
|
-
- [Development workflow](CONTRIBUTING.md#development-workflow)
|
|
584
|
-
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
|
|
585
|
-
- [Code of conduct](CODE_OF_CONDUCT.md)
|
|
586
|
-
|
|
587
|
-
## License
|
|
588
|
-
|
|
589
|
-
MIT
|
|
@@ -58,7 +58,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
58
58
|
private var sdkCredential: String? = null
|
|
59
59
|
|
|
60
60
|
// ── Timing constants ──────────────────────────────────────────────────────
|
|
61
|
-
private var campaignFetchCooldown = 5_000L // ms
|
|
62
61
|
private var proximityDistanceThreshold = 0.75
|
|
63
62
|
private var impressionEventInterval = 10_000L // ms
|
|
64
63
|
private val impressionDistance = 2.0
|
|
@@ -69,8 +68,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
69
68
|
private val lastProximityEventSent = mutableMapOf<String, Long>()
|
|
70
69
|
private val lastProximityDistance = mutableMapOf<String, Double>()
|
|
71
70
|
private val lastImpressionEventSent = mutableMapOf<String, Long>()
|
|
72
|
-
private val lastCampaignFetchAttempt = mutableMapOf<String, Long>()
|
|
73
|
-
private val fetchInProgress = mutableMapOf<String, Boolean>()
|
|
74
71
|
private val proximityEventInProgress = mutableMapOf<String, Boolean>()
|
|
75
72
|
private val impressionEventInProgress = mutableMapOf<String, Boolean>()
|
|
76
73
|
|
|
@@ -224,7 +221,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
224
221
|
}
|
|
225
222
|
|
|
226
223
|
override fun clearDebounceCache(promise: Promise) {
|
|
227
|
-
lastCampaignFetchAttempt.clear(); fetchInProgress.clear()
|
|
228
224
|
promise.resolve("Debounce cache cleared")
|
|
229
225
|
}
|
|
230
226
|
|
|
@@ -233,8 +229,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
233
229
|
val map = WritableNativeMap()
|
|
234
230
|
for ((key, _) in activeCampaigns) {
|
|
235
231
|
val entry = WritableNativeMap()
|
|
236
|
-
lastCampaignFetchAttempt[key]?.let { entry.putDouble("lastFetchAttempt", it.toDouble()) }
|
|
237
|
-
fetchInProgress[key]?.let { entry.putBoolean("fetchInProgress", it) }
|
|
238
232
|
lastProximityEventSent[key]?.let { entry.putDouble("lastProximityEvent", it.toDouble()) }
|
|
239
233
|
lastImpressionEventSent[key]?.let { entry.putDouble("lastImpressionEvent", it.toDouble()) }
|
|
240
234
|
map.putMap(key, entry)
|
|
@@ -299,30 +293,31 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
299
293
|
|
|
300
294
|
if (distance <= 0 || distance > maxDetectionDistance) continue
|
|
301
295
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
296
|
+
val isFirst = !lastProximityEventSent.containsKey(key)
|
|
297
|
+
|
|
298
|
+
if (isFirst) {
|
|
299
|
+
// First detection — send proximity immediately
|
|
300
|
+
sendProximity("NEARBY", key, distance)
|
|
301
|
+
lastProximityDistance[key] = distance
|
|
302
|
+
lastProximityEventSent[key] = now
|
|
303
|
+
} else if (distance >= 1.0) {
|
|
304
|
+
val lastDist = lastProximityDistance[key] ?: 0.0
|
|
305
|
+
if (abs(distance - lastDist) >= proximityDistanceThreshold) {
|
|
306
|
+
// Distance changed significantly — send proximity update
|
|
305
307
|
sendProximity("NEARBY", key, distance)
|
|
306
308
|
lastProximityDistance[key] = distance
|
|
307
309
|
lastProximityEventSent[key] = now
|
|
308
|
-
} else if (distance >= 1.0) {
|
|
309
|
-
val lastDist = lastProximityDistance[key] ?: 0.0
|
|
310
|
-
if (abs(distance - lastDist) >= proximityDistanceThreshold) {
|
|
311
|
-
sendProximity("NEARBY", key, distance)
|
|
312
|
-
lastProximityDistance[key] = distance
|
|
313
|
-
lastProximityEventSent[key] = now
|
|
314
|
-
}
|
|
315
310
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Impression heartbeat when user is very close AND campaign exists
|
|
314
|
+
val campaign = activeCampaigns[key]
|
|
315
|
+
if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
|
|
316
|
+
val lastImp = lastImpressionEventSent[key]
|
|
317
|
+
if (lastImp == null || now - lastImp >= impressionEventInterval) {
|
|
318
|
+
sendImpression(key, distance)
|
|
319
|
+
lastImpressionEventSent[key] = now
|
|
323
320
|
}
|
|
324
|
-
} else {
|
|
325
|
-
fetchCampaign(major, minor)
|
|
326
321
|
}
|
|
327
322
|
}
|
|
328
323
|
}
|
|
@@ -409,8 +404,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
409
404
|
lastProximityEventSent.remove(key)
|
|
410
405
|
lastProximityDistance.remove(key)
|
|
411
406
|
lastImpressionEventSent.remove(key)
|
|
412
|
-
lastCampaignFetchAttempt.remove(key)
|
|
413
|
-
fetchInProgress.remove(key)
|
|
414
407
|
proximityEventInProgress.remove(key)
|
|
415
408
|
impressionEventInProgress.remove(key)
|
|
416
409
|
Log.d(TAG, "Cleaned up state for beacon $key")
|
|
@@ -533,51 +526,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
533
526
|
return sb.toString()
|
|
534
527
|
}
|
|
535
528
|
|
|
536
|
-
// ── Campaign fetching ─────────────────────────────────────────────────────
|
|
537
|
-
|
|
538
|
-
private fun fetchCampaign(major: Int, minor: Int) {
|
|
539
|
-
val key = beaconKey(major, minor)
|
|
540
|
-
if (activeCampaigns.containsKey(key)) return
|
|
541
|
-
if (fetchInProgress[key] == true) return
|
|
542
|
-
|
|
543
|
-
val last = lastCampaignFetchAttempt[key]
|
|
544
|
-
if (last != null && System.currentTimeMillis() - last < campaignFetchCooldown) return
|
|
545
|
-
|
|
546
|
-
lastCampaignFetchAttempt[key] = System.currentTimeMillis()
|
|
547
|
-
fetchInProgress[key] = true
|
|
548
|
-
|
|
549
|
-
val payload = mutableMapOf<String, Any?>("beacon_id" to key)
|
|
550
|
-
|
|
551
|
-
post("$apiBasePath/distribute", payload) { status, body ->
|
|
552
|
-
fetchInProgress[key] = false
|
|
553
|
-
if (status != 200) {
|
|
554
|
-
Log.w(TAG, "Campaign fetch status $status for beacon $key")
|
|
555
|
-
return@post
|
|
556
|
-
}
|
|
557
|
-
try {
|
|
558
|
-
val json = parseJsonObject(body) ?: return@post
|
|
559
|
-
val dataObj = json["data"] as? Map<*, *> ?: return@post
|
|
560
|
-
val screen = dataObj["screen"] as? Map<*, *> ?: return@post
|
|
561
|
-
val screenId = (screen["id"] as? Number)?.toInt() ?: return@post
|
|
562
|
-
|
|
563
|
-
var campaignId: Int? = null
|
|
564
|
-
var inQueue = false
|
|
565
|
-
val campaignObj = dataObj["campaign"] as? Map<*, *>
|
|
566
|
-
if (campaignObj != null) {
|
|
567
|
-
campaignId = (campaignObj["id"] as? Number)?.toInt()
|
|
568
|
-
inQueue = campaignObj["inQueue"] as? Boolean ?: false
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
activeCampaigns[key] = CampaignData(
|
|
572
|
-
campaignId = campaignId, screenId = screenId,
|
|
573
|
-
sessionId = null, inQueue = inQueue, major = major, minor = minor)
|
|
574
|
-
Log.d(TAG, "Campaign loaded for beacon $key — screenId=$screenId")
|
|
575
|
-
} catch (e: Exception) {
|
|
576
|
-
Log.e(TAG, "Campaign JSON parse error for $key: ${e.message}")
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
529
|
// ── Tracking events ───────────────────────────────────────────────────────
|
|
582
530
|
|
|
583
531
|
private fun sendTracking(
|
|
@@ -593,38 +541,88 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
593
541
|
if (isImpression) impressionEventInProgress[key] = true
|
|
594
542
|
else proximityEventInProgress[key] = true
|
|
595
543
|
|
|
596
|
-
|
|
597
|
-
if (campaign == null) {
|
|
598
|
-
if (isImpression) impressionEventInProgress[key] = false
|
|
599
|
-
else proximityEventInProgress[key] = false
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
|
-
if (isImpression && (campaign.campaignId == null || campaign.inQueue)) {
|
|
603
|
-
impressionEventInProgress[key] = false; return
|
|
604
|
-
}
|
|
605
|
-
|
|
544
|
+
// Build payload — proximity events use beacon_id, impressions use screen_id
|
|
606
545
|
val payload = mutableMapOf<String, Any?>(
|
|
607
546
|
"event_type" to eventType,
|
|
608
|
-
"distance" to distance
|
|
609
|
-
"screen_id" to campaign.screenId
|
|
547
|
+
"distance" to distance
|
|
610
548
|
)
|
|
611
|
-
|
|
612
|
-
|
|
549
|
+
|
|
550
|
+
if (isImpression) {
|
|
551
|
+
// Impressions require campaign to exist
|
|
552
|
+
val campaign = activeCampaigns[key]
|
|
553
|
+
if (campaign == null || campaign.campaignId == null || campaign.inQueue) {
|
|
554
|
+
impressionEventInProgress[key] = false
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
payload["screen_id"] = campaign.screenId
|
|
558
|
+
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
559
|
+
campaign.sessionId?.let { payload["session_id"] = it }
|
|
560
|
+
} else {
|
|
561
|
+
// Proximity events send beacon_id — backend resolves screen
|
|
562
|
+
payload["beacon_id"] = key
|
|
563
|
+
// Also send screen/campaign/session if we already have them (after first proximity)
|
|
564
|
+
activeCampaigns[key]?.let { campaign ->
|
|
565
|
+
payload["screen_id"] = campaign.screenId
|
|
566
|
+
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
567
|
+
campaign.sessionId?.let { payload["session_id"] = it }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
613
570
|
|
|
614
571
|
post(endpoint, payload) { status, body ->
|
|
615
572
|
if (status in 200..299) {
|
|
616
573
|
Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
|
|
617
|
-
|
|
574
|
+
|
|
575
|
+
// Parse screen_id, campaign_id, session_id from proximity response
|
|
576
|
+
if (!isImpression) {
|
|
618
577
|
try {
|
|
619
578
|
val json = parseJsonObject(body)
|
|
620
|
-
val
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
579
|
+
val dataObj = json?.get("data") as? Map<*, *>
|
|
580
|
+
|
|
581
|
+
var screenId: Int? = null
|
|
582
|
+
var campaignId: Int? = null
|
|
583
|
+
var sessionId: String? = null
|
|
584
|
+
var inQueue = false
|
|
585
|
+
|
|
586
|
+
// Extract from event object (contains session_id)
|
|
587
|
+
val eventObj = dataObj?.get("event") as? Map<*, *>
|
|
588
|
+
if (eventObj != null) {
|
|
589
|
+
sessionId = eventObj["session_id"] as? String
|
|
590
|
+
screenId = (eventObj["screen_id"] as? Number)?.toInt()
|
|
626
591
|
}
|
|
627
|
-
|
|
592
|
+
|
|
593
|
+
// Extract from screen object
|
|
594
|
+
if (screenId == null) {
|
|
595
|
+
val screenObj = dataObj?.get("screen") as? Map<*, *>
|
|
596
|
+
screenId = (screenObj?.get("id") as? Number)?.toInt()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Extract from campaign object
|
|
600
|
+
val campaignObj = dataObj?.get("campaign") as? Map<*, *>
|
|
601
|
+
if (campaignObj != null) {
|
|
602
|
+
campaignId = (campaignObj["id"] as? Number)?.toInt()
|
|
603
|
+
inQueue = campaignObj["inQueue"] as? Boolean ?: false
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Store campaign data if we got screen_id
|
|
607
|
+
if (screenId != null) {
|
|
608
|
+
// Parse major/minor from key
|
|
609
|
+
val parts = key.split("_")
|
|
610
|
+
val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
|
|
611
|
+
val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
|
|
612
|
+
|
|
613
|
+
activeCampaigns[key] = CampaignData(
|
|
614
|
+
campaignId = campaignId,
|
|
615
|
+
screenId = screenId,
|
|
616
|
+
sessionId = sessionId,
|
|
617
|
+
inQueue = inQueue,
|
|
618
|
+
major = major,
|
|
619
|
+
minor = minor
|
|
620
|
+
)
|
|
621
|
+
Log.d(TAG, "Stored campaign — screenId=$screenId, campaignId=$campaignId, sessionId=$sessionId")
|
|
622
|
+
}
|
|
623
|
+
} catch (e: Exception) {
|
|
624
|
+
Log.w(TAG, "Error parsing proximity response: ${e.message}")
|
|
625
|
+
}
|
|
628
626
|
}
|
|
629
627
|
} else if (status == 429) {
|
|
630
628
|
val penalty = System.currentTimeMillis() + 10_000L
|
|
@@ -81,7 +81,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
81
81
|
private let beaconUUID = UUID(uuidString: "f7826da6-4fa2-4e98-8024-bc5b71e0893e")!
|
|
82
82
|
|
|
83
83
|
// ── Timing constants ──────────────────────────────────────────────────────
|
|
84
|
-
private let campaignFetchCooldown: TimeInterval = 5.0
|
|
85
84
|
private let proximityDistanceThreshold: Double = 0.75
|
|
86
85
|
private let impressionEventInterval: TimeInterval = 10.0
|
|
87
86
|
private let impressionDistance: Double = 2.0
|
|
@@ -92,9 +91,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
92
91
|
private var lastProximityEventSent: [String: Date] = [:]
|
|
93
92
|
private var lastProximityDistance: [String: Double] = [:]
|
|
94
93
|
private var lastImpressionEventSent: [String: Date] = [:]
|
|
95
|
-
private var lastCampaignFetchAttempt: [String: Date] = [:]
|
|
96
|
-
private var fetchInProgress: [String: Bool] = [:]
|
|
97
|
-
private var fetchRetryCount: [String: Int] = [:]
|
|
98
94
|
private var proximityEventInProgress: [String: Bool] = [:]
|
|
99
95
|
private var impressionEventInProgress: [String: Bool] = [:]
|
|
100
96
|
|
|
@@ -313,9 +309,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
313
309
|
withResolve resolve: @escaping (Any?) -> Void,
|
|
314
310
|
reject: @escaping (String?, String?, Error?) -> Void
|
|
315
311
|
) {
|
|
316
|
-
lastCampaignFetchAttempt.removeAll()
|
|
317
|
-
fetchInProgress.removeAll()
|
|
318
|
-
fetchRetryCount.removeAll()
|
|
319
312
|
resolve("Debounce cache cleared")
|
|
320
313
|
}
|
|
321
314
|
|
|
@@ -329,12 +322,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
329
322
|
var status: [String: Any] = [:]
|
|
330
323
|
for (key, _) in activeCampaigns {
|
|
331
324
|
var entry: [String: Any] = [:]
|
|
332
|
-
if let lastFetch = lastCampaignFetchAttempt[key] {
|
|
333
|
-
entry["lastFetchAttempt"] = lastFetch.timeIntervalSince1970
|
|
334
|
-
}
|
|
335
|
-
if let inProg = fetchInProgress[key] {
|
|
336
|
-
entry["fetchInProgress"] = inProg
|
|
337
|
-
}
|
|
338
325
|
if let lastProx = lastProximityEventSent[key] {
|
|
339
326
|
entry["lastProximityEvent"] = lastProx.timeIntervalSince1970
|
|
340
327
|
}
|
|
@@ -592,70 +579,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
592
579
|
}.resume()
|
|
593
580
|
}
|
|
594
581
|
|
|
595
|
-
// MARK: - Campaign Fetching
|
|
596
|
-
|
|
597
|
-
/// Exponential cooldown: 5 s → 15 s → 45 s. After 3 failures the key is
|
|
598
|
-
/// considered permanently failed until the beacon region is re-entered.
|
|
599
|
-
private func fetchCooldown(for key: String) -> TimeInterval {
|
|
600
|
-
let retries = fetchRetryCount[key] ?? 0
|
|
601
|
-
return campaignFetchCooldown * pow(3.0, Double(min(retries, 3)))
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
private func fetchCampaign(major: Int, minor: Int) {
|
|
605
|
-
let key = beaconKey(major: major, minor: minor)
|
|
606
|
-
guard activeCampaigns[key] == nil else { return }
|
|
607
|
-
guard fetchInProgress[key] != true else { return }
|
|
608
|
-
// Give up after 3 consecutive failures (cooldown would be 135 s+)
|
|
609
|
-
if (fetchRetryCount[key] ?? 0) > 3 { return }
|
|
610
|
-
|
|
611
|
-
if let last = lastCampaignFetchAttempt[key],
|
|
612
|
-
Date().timeIntervalSince(last) < fetchCooldown(for: key) { return }
|
|
613
|
-
|
|
614
|
-
lastCampaignFetchAttempt[key] = Date()
|
|
615
|
-
fetchInProgress[key] = true
|
|
616
|
-
|
|
617
|
-
let payload: [String: Any] = ["beacon_id": key]
|
|
618
|
-
|
|
619
|
-
post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
|
|
620
|
-
guard let self = self else { return }
|
|
621
|
-
defer { self.fetchInProgress[key] = false }
|
|
622
|
-
|
|
623
|
-
guard case .success(let (status, data)) = result, status == 200 else {
|
|
624
|
-
if case .success(let (s, _)) = result {
|
|
625
|
-
print("❌ SpotnySDK: Campaign fetch status \(s) for beacon \(key)")
|
|
626
|
-
}
|
|
627
|
-
// Increment retry count so next attempt uses a longer cooldown
|
|
628
|
-
self.fetchRetryCount[key] = (self.fetchRetryCount[key] ?? 0) + 1
|
|
629
|
-
logToFile("❌ Campaign fetch failed for beacon \(key) (retry \(self.fetchRetryCount[key] ?? 0))")
|
|
630
|
-
return
|
|
631
|
-
}
|
|
632
|
-
do {
|
|
633
|
-
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
634
|
-
let dataObj = json["data"] as? [String: Any],
|
|
635
|
-
let screen = dataObj["screen"] as? [String: Any],
|
|
636
|
-
let screenId = screen["id"] as? Int else {
|
|
637
|
-
print("⚠️ SpotnySDK: Unexpected campaign response format for \(key)")
|
|
638
|
-
return
|
|
639
|
-
}
|
|
640
|
-
var campaignId: Int?
|
|
641
|
-
var inQueue = false
|
|
642
|
-
if let campaignObj = dataObj["campaign"] as? [String: Any],
|
|
643
|
-
let cid = campaignObj["id"] as? Int {
|
|
644
|
-
campaignId = cid
|
|
645
|
-
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
646
|
-
}
|
|
647
|
-
self.activeCampaigns[key] = CampaignData(
|
|
648
|
-
campaignId: campaignId, screenId: screenId,
|
|
649
|
-
sessionId: nil, inQueue: inQueue, major: major, minor: minor)
|
|
650
|
-
self.fetchRetryCount.removeValue(forKey: key) // reset on success
|
|
651
|
-
logToFile("✅ Campaign loaded for beacon \(key) — screenId=\(screenId)")
|
|
652
|
-
print("✅ SpotnySDK: Campaign loaded for beacon \(key) — screenId=\(screenId)")
|
|
653
|
-
} catch {
|
|
654
|
-
print("❌ SpotnySDK: JSON parse error for beacon \(key): \(error)")
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
582
|
// MARK: - Tracking
|
|
660
583
|
|
|
661
584
|
private func sendTracking(
|
|
@@ -671,24 +594,33 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
671
594
|
if isImpression { impressionEventInProgress[key] = true }
|
|
672
595
|
else { proximityEventInProgress[key] = true }
|
|
673
596
|
|
|
674
|
-
|
|
675
|
-
if isImpression { impressionEventInProgress[key] = false }
|
|
676
|
-
else { proximityEventInProgress[key] = false }
|
|
677
|
-
return
|
|
678
|
-
}
|
|
679
|
-
if isImpression {
|
|
680
|
-
guard let _ = campaign.campaignId, !campaign.inQueue else {
|
|
681
|
-
impressionEventInProgress[key] = false; return
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
597
|
+
// Build payload — proximity events use beacon_id, impressions use screen_id
|
|
685
598
|
var payload: [String: Any] = [
|
|
686
599
|
"event_type": eventType,
|
|
687
|
-
"distance": distance
|
|
688
|
-
"screen_id": campaign.screenId
|
|
600
|
+
"distance": distance
|
|
689
601
|
]
|
|
690
|
-
|
|
691
|
-
if
|
|
602
|
+
|
|
603
|
+
if isImpression {
|
|
604
|
+
// Impressions require campaign to exist
|
|
605
|
+
guard let campaign = activeCampaigns[key],
|
|
606
|
+
let _ = campaign.campaignId,
|
|
607
|
+
!campaign.inQueue else {
|
|
608
|
+
impressionEventInProgress[key] = false
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
payload["screen_id"] = campaign.screenId
|
|
612
|
+
if let cid = campaign.campaignId { payload["campaign_id"] = cid }
|
|
613
|
+
if let sid = campaign.sessionId { payload["session_id"] = sid }
|
|
614
|
+
} else {
|
|
615
|
+
// Proximity events send beacon_id — backend resolves screen
|
|
616
|
+
payload["beacon_id"] = key
|
|
617
|
+
// Also send screen/campaign/session if we already have them (after first proximity)
|
|
618
|
+
if let campaign = activeCampaigns[key] {
|
|
619
|
+
payload["screen_id"] = campaign.screenId
|
|
620
|
+
if let cid = campaign.campaignId { payload["campaign_id"] = cid }
|
|
621
|
+
if let sid = campaign.sessionId { payload["session_id"] = sid }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
692
624
|
|
|
693
625
|
post(endpoint: endpoint, payload: payload) { [weak self] result in
|
|
694
626
|
guard let self = self else { return }
|
|
@@ -697,17 +629,52 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
697
629
|
if 200...299 ~= status {
|
|
698
630
|
print("✅ SpotnySDK: \(eventType) sent — distance \(String(format: "%.2f", distance))m")
|
|
699
631
|
logToFile("✅ \(eventType) sent — beacon \(key) @ \(String(format: "%.2f", distance))m")
|
|
700
|
-
|
|
632
|
+
|
|
633
|
+
// Parse screen_id, campaign_id, session_id from proximity response
|
|
634
|
+
if !isImpression,
|
|
701
635
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
702
|
-
let dObj = json["data"] as? [String: Any]
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
636
|
+
let dObj = json["data"] as? [String: Any] {
|
|
637
|
+
|
|
638
|
+
var screenId: Int?
|
|
639
|
+
var campaignId: Int?
|
|
640
|
+
var sessionId: String?
|
|
641
|
+
var inQueue = false
|
|
642
|
+
|
|
643
|
+
// Extract from event object (contains session_id)
|
|
644
|
+
if let ev = dObj["event"] as? [String: Any] {
|
|
645
|
+
if let sid = ev["session_id"] as? String { sessionId = sid }
|
|
646
|
+
if let scId = ev["screen_id"] as? Int { screenId = scId }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Extract from screen object
|
|
650
|
+
if screenId == nil, let screen = dObj["screen"] as? [String: Any] {
|
|
651
|
+
screenId = screen["id"] as? Int
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Extract from campaign object
|
|
655
|
+
if let campaignObj = dObj["campaign"] as? [String: Any] {
|
|
656
|
+
campaignId = campaignObj["id"] as? Int
|
|
657
|
+
inQueue = campaignObj["inQueue"] as? Bool ?? false
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Store campaign data if we got screen_id
|
|
661
|
+
if let scId = screenId {
|
|
662
|
+
// Parse major/minor from key
|
|
663
|
+
let parts = key.components(separatedBy: "_")
|
|
664
|
+
let major = parts.count > 0 ? Int(parts[0]) ?? 0 : 0
|
|
665
|
+
let minor = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
|
|
666
|
+
|
|
667
|
+
let updated = CampaignData(
|
|
668
|
+
campaignId: campaignId,
|
|
669
|
+
screenId: scId,
|
|
670
|
+
sessionId: sessionId,
|
|
671
|
+
inQueue: inQueue,
|
|
672
|
+
major: major,
|
|
673
|
+
minor: minor
|
|
674
|
+
)
|
|
675
|
+
self.activeCampaigns[key] = updated
|
|
676
|
+
print("✅ SpotnySDK: Stored campaign — screenId=\(scId), campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil")")
|
|
677
|
+
}
|
|
711
678
|
}
|
|
712
679
|
} else if status == 429 {
|
|
713
680
|
let penalty = Date().addingTimeInterval(10)
|
|
@@ -743,9 +710,6 @@ public class SpotnyBeaconScanner: NSObject {
|
|
|
743
710
|
lastProximityEventSent.removeValue(forKey: key)
|
|
744
711
|
lastProximityDistance.removeValue(forKey: key)
|
|
745
712
|
lastImpressionEventSent.removeValue(forKey: key)
|
|
746
|
-
lastCampaignFetchAttempt.removeValue(forKey: key)
|
|
747
|
-
fetchInProgress.removeValue(forKey: key)
|
|
748
|
-
fetchRetryCount.removeValue(forKey: key)
|
|
749
713
|
proximityEventInProgress.removeValue(forKey: key)
|
|
750
714
|
impressionEventInProgress.removeValue(forKey: key)
|
|
751
715
|
print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
|
|
@@ -835,35 +799,36 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
|
|
|
835
799
|
|
|
836
800
|
guard distance > 0 && distance <= maxDetectionDistance else { continue }
|
|
837
801
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
802
|
+
let isFirst = lastProximityEventSent[key] == nil
|
|
803
|
+
|
|
804
|
+
if isFirst {
|
|
805
|
+
// First detection — send proximity immediately
|
|
806
|
+
sendProximity(eventType: "NEARBY", key: key, distance: distance)
|
|
807
|
+
lastProximityDistance[key] = distance
|
|
808
|
+
lastProximityEventSent[key] = now
|
|
809
|
+
} else if distance >= 1.0,
|
|
810
|
+
let lastDist = lastProximityDistance[key],
|
|
811
|
+
abs(distance - lastDist) >= proximityDistanceThreshold {
|
|
812
|
+
// Distance changed significantly — send proximity update
|
|
813
|
+
sendProximity(eventType: "NEARBY", key: key, distance: distance)
|
|
814
|
+
lastProximityDistance[key] = distance
|
|
815
|
+
lastProximityEventSent[key] = now
|
|
816
|
+
}
|
|
852
817
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
} else {
|
|
818
|
+
// Impression heartbeat when user is very close AND campaign exists
|
|
819
|
+
if let campaign = activeCampaigns[key],
|
|
820
|
+
let _ = campaign.campaignId,
|
|
821
|
+
!campaign.inQueue,
|
|
822
|
+
distance <= impressionDistance {
|
|
823
|
+
if let last = lastImpressionEventSent[key] {
|
|
824
|
+
if now.timeIntervalSince(last) >= impressionEventInterval {
|
|
861
825
|
sendImpression(key: key, distance: distance)
|
|
862
826
|
lastImpressionEventSent[key] = now
|
|
863
827
|
}
|
|
828
|
+
} else {
|
|
829
|
+
sendImpression(key: key, distance: distance)
|
|
830
|
+
lastImpressionEventSent[key] = now
|
|
864
831
|
}
|
|
865
|
-
} else {
|
|
866
|
-
fetchCampaign(major: major, minor: minor)
|
|
867
832
|
}
|
|
868
833
|
}
|
|
869
834
|
}
|
|
@@ -879,18 +844,6 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
|
|
|
879
844
|
if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
|
|
880
845
|
}
|
|
881
846
|
}
|
|
882
|
-
|
|
883
|
-
// Parse major/minor from named regions (e.g. "SpotnySDK_52885_35127")
|
|
884
|
-
let parts = region.identifier.components(separatedBy: "_")
|
|
885
|
-
if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
|
|
886
|
-
let key = beaconKey(major: major, minor: minor)
|
|
887
|
-
// Reset fetch state on re-entry so retry backoff starts fresh
|
|
888
|
-
lastCampaignFetchAttempt.removeValue(forKey: key)
|
|
889
|
-
fetchRetryCount.removeValue(forKey: key)
|
|
890
|
-
if activeCampaigns[key] == nil {
|
|
891
|
-
fetchCampaign(major: major, minor: minor)
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
847
|
}
|
|
895
848
|
|
|
896
849
|
public func beaconManager(_ manager: KTKBeaconManager, didExitRegion region: KTKBeaconRegion) {
|