spotny-sdk 1.0.26 → 1.0.28
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.
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
package com.spotnysdk
|
|
2
2
|
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
3
5
|
import android.content.Context
|
|
6
|
+
import android.os.Build
|
|
4
7
|
import android.os.RemoteException
|
|
5
8
|
import android.util.Log
|
|
9
|
+
import androidx.core.app.NotificationCompat
|
|
6
10
|
import com.facebook.react.bridge.*
|
|
7
11
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
8
12
|
import org.altbeacon.beacon.*
|
|
@@ -22,8 +26,6 @@ private const val TAG = "SpotnySDK"
|
|
|
22
26
|
// ── Campaign data ─────────────────────────────────────────────────────────────
|
|
23
27
|
private data class CampaignData(
|
|
24
28
|
val campaignId: Int?,
|
|
25
|
-
|
|
26
|
-
val sessionId: String?,
|
|
27
29
|
val inQueue: Boolean,
|
|
28
30
|
val major: Int,
|
|
29
31
|
val minor: Int
|
|
@@ -72,7 +74,16 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
72
74
|
private val eventInProgress = mutableMapOf<String, Boolean>()
|
|
73
75
|
private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
|
|
74
76
|
private val campaignFetched = mutableMapOf<String, Boolean>()
|
|
75
|
-
private val campaignFetchCooldown = mutableMapOf<String, Long>() //
|
|
77
|
+
private val campaignFetchCooldown = mutableMapOf<String, Long>() // epoch-ms backoff
|
|
78
|
+
// 410 from /track means backend session expired — stop heartbeats until next NEARBY
|
|
79
|
+
private val impressionDisabled = mutableMapOf<String, Boolean>()
|
|
80
|
+
// Last time each beacon was seen in ranging — used to detect slow drift exits
|
|
81
|
+
private val beaconLastSeen = mutableMapOf<String, Long>()
|
|
82
|
+
|
|
83
|
+
// ── JS event deduplication ────────────────────────────────────────────────
|
|
84
|
+
private var lastRangedSignature = ""
|
|
85
|
+
private var lastRangedEmit = 0L
|
|
86
|
+
private var lastSessionHeartbeat = 0L
|
|
76
87
|
|
|
77
88
|
// ── Module registration ───────────────────────────────────────────────────
|
|
78
89
|
|
|
@@ -150,7 +161,7 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
150
161
|
?.let { maxDetectionDistance = it; Log.d(TAG, "maxDetectionDistance = $it m") }
|
|
151
162
|
config?.getString("apiKey")?.let { apiKey = it; Log.d(TAG, "apiKey = $it") }
|
|
152
163
|
config?.getDouble("distanceCorrectionFactor")
|
|
153
|
-
.takeIf { config?.hasKey("distanceCorrectionFactor") == true && it > 0 }
|
|
164
|
+
.takeIf { config?.hasKey("distanceCorrectionFactor") == true && (it ?: 0.0) > 0 }
|
|
154
165
|
?.let { distanceCorrectionFactor = it; Log.d(TAG, "distanceCorrectionFactor = $it") }
|
|
155
166
|
val token = config?.getString("token")?.takeIf { it.isNotBlank() }
|
|
156
167
|
if (token == null) {
|
|
@@ -171,13 +182,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
171
182
|
|
|
172
183
|
// Send token + apiKey + identifierId to backend — response returns a JWT used for all subsequent calls
|
|
173
184
|
// sdkToken is null here so no Authorization header is injected on this call
|
|
174
|
-
val verifyPayload = mapOf("
|
|
185
|
+
val verifyPayload = mapOf("api_token" to token, "api_key" to key, "identifier_id" to uid, "device_id" to getDeviceId())
|
|
175
186
|
post("$apiBasePath/verify", verifyPayload) { status, body ->
|
|
176
187
|
when {
|
|
177
188
|
status in 200..299 -> {
|
|
178
189
|
val verifyJson = try { parseJsonObject(body) } catch (_: Exception) { null }
|
|
179
190
|
val verifyData = verifyJson?.get("data") as? Map<*, *>
|
|
180
|
-
val jwt = (verifyData?.get("
|
|
191
|
+
val jwt = (verifyData?.get("token") as? String)?.takeIf { it.isNotBlank() }
|
|
181
192
|
if (jwt == null) {
|
|
182
193
|
promise.reject("VERIFY_FAILED", "SDK verification response missing JWT")
|
|
183
194
|
return@post
|
|
@@ -262,32 +273,66 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
262
273
|
private val rangeNotifier = RangeNotifier { beacons, region ->
|
|
263
274
|
val now = System.currentTimeMillis()
|
|
264
275
|
|
|
265
|
-
//
|
|
266
|
-
|
|
276
|
+
// Session heartbeat every 60 s to prevent TTL expiry on a live session
|
|
277
|
+
if (now - lastSessionHeartbeat >= 60_000L) {
|
|
278
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE).edit()
|
|
279
|
+
prefs.putLong("sessionTimestamp", now); prefs.apply()
|
|
280
|
+
lastSessionHeartbeat = now
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Build JS event payload for all ranged beacons (with debounce)
|
|
284
|
+
val beaconList = mutableListOf<Map<String, Any>>()
|
|
267
285
|
for (beacon in beacons) {
|
|
268
286
|
val raw = beacon.distance
|
|
269
287
|
val adjusted = raw * distanceCorrectionFactor
|
|
270
288
|
if (adjusted <= 0 || adjusted > maxDetectionDistance) continue
|
|
289
|
+
beaconList.add(mapOf(
|
|
290
|
+
"uuid" to (beacon.id1?.toString() ?: BEACON_UUID),
|
|
291
|
+
"major" to (beacon.id2?.toInt() ?: 0),
|
|
292
|
+
"minor" to (beacon.id3?.toInt() ?: 0),
|
|
293
|
+
"distance" to adjusted,
|
|
294
|
+
"rssi" to beacon.rssi,
|
|
295
|
+
"proximity" to proximityLabel(adjusted)
|
|
296
|
+
))
|
|
297
|
+
}
|
|
271
298
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
299
|
+
if (beaconList.isNotEmpty()) {
|
|
300
|
+
val signature = beaconList
|
|
301
|
+
.map { "${it["major"]}_${it["minor"]}_${it["proximity"]}" }
|
|
302
|
+
.sorted().joinToString(",")
|
|
303
|
+
val stale = now - lastRangedEmit >= debounceInterval
|
|
304
|
+
if (signature != lastRangedSignature || stale) {
|
|
305
|
+
val jsBeacons = WritableNativeArray()
|
|
306
|
+
for (b in beaconList) {
|
|
307
|
+
val bMap = WritableNativeMap().apply {
|
|
308
|
+
putString("uuid", b["uuid"] as String)
|
|
309
|
+
putInt("major", b["major"] as Int)
|
|
310
|
+
putInt("minor", b["minor"] as Int)
|
|
311
|
+
putDouble("distance", b["distance"] as Double)
|
|
312
|
+
putInt("rssi", b["rssi"] as Int)
|
|
313
|
+
putString("proximity", b["proximity"] as String)
|
|
314
|
+
}
|
|
315
|
+
jsBeacons.pushMap(bMap)
|
|
316
|
+
}
|
|
317
|
+
val payload = WritableNativeMap().apply {
|
|
318
|
+
putArray("beacons", jsBeacons)
|
|
319
|
+
putString("region", region.uniqueId)
|
|
320
|
+
}
|
|
321
|
+
sendEvent("onBeaconsRanged", payload)
|
|
322
|
+
lastRangedSignature = signature
|
|
323
|
+
lastRangedEmit = now
|
|
279
324
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (jsBeacons.size() > 0) {
|
|
325
|
+
} else if (lastRangedSignature != "") {
|
|
326
|
+
// All beacons left range — emit empty to let JS clear its state
|
|
283
327
|
val payload = WritableNativeMap().apply {
|
|
284
|
-
putArray("beacons",
|
|
285
|
-
putString("region",
|
|
328
|
+
putArray("beacons", WritableNativeArray())
|
|
329
|
+
putString("region", region.uniqueId)
|
|
286
330
|
}
|
|
287
331
|
sendEvent("onBeaconsRanged", payload)
|
|
332
|
+
lastRangedSignature = ""
|
|
288
333
|
}
|
|
289
334
|
|
|
290
|
-
// Per-beacon logic
|
|
335
|
+
// Per-beacon campaign & proximity logic
|
|
291
336
|
for (beacon in beacons) {
|
|
292
337
|
val major = beacon.id2?.toInt() ?: continue
|
|
293
338
|
val minor = beacon.id3?.toInt() ?: continue
|
|
@@ -296,29 +341,55 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
296
341
|
|
|
297
342
|
if (distance <= 0 || distance > maxDetectionDistance) continue
|
|
298
343
|
|
|
299
|
-
// FIX #9: null lastDist already implies first detection
|
|
300
344
|
val lastDist = lastProximityDistance[key]
|
|
301
345
|
if (lastDist == null || (distance >= 1.0 && abs(distance - lastDist) >= proximityDistanceThreshold)) {
|
|
346
|
+
if (lastDist == null) {
|
|
347
|
+
val enterPayload = WritableNativeMap().apply {
|
|
348
|
+
putInt("major", major)
|
|
349
|
+
putInt("minor", minor)
|
|
350
|
+
putString("beaconId", key)
|
|
351
|
+
putDouble("distance", distance)
|
|
352
|
+
putString("region", "SpotnySDK_GeneralRegion")
|
|
353
|
+
}
|
|
354
|
+
sendEvent("onBeaconRangeEnter", enterPayload)
|
|
355
|
+
sendLocalNotification(
|
|
356
|
+
"📡 Beacon Detected",
|
|
357
|
+
"Major: $major, Minor: $minor — ${"%,.1f".format(distance)}m away"
|
|
358
|
+
)
|
|
359
|
+
}
|
|
302
360
|
sendProximity("NEARBY", key, distance)
|
|
303
361
|
lastProximityDistance[key] = distance
|
|
304
362
|
lastProximityEventSent[key] = now
|
|
305
363
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
364
|
+
|
|
365
|
+
beaconLastSeen[key] = now
|
|
366
|
+
|
|
367
|
+
// Fetch campaign when distance <= 5m
|
|
368
|
+
if (distance <= 5.0 && campaignFetched[key] != true) {
|
|
309
369
|
fetchCampaign(key, major, minor)
|
|
310
370
|
}
|
|
311
|
-
|
|
371
|
+
|
|
312
372
|
// Impression heartbeat when user is very close AND campaign exists
|
|
313
373
|
val campaign = activeCampaigns[key]
|
|
314
|
-
if (campaign?.campaignId != null && campaign.inQueue
|
|
315
|
-
|
|
316
|
-
|
|
374
|
+
if (campaign?.campaignId != null && !campaign.inQueue &&
|
|
375
|
+
impressionDisabled[key] != true && distance <= impressionDistance) {
|
|
376
|
+
val lastImp = lastImpressionEventSent[key] ?: 0L
|
|
377
|
+
if (now - lastImp >= impressionEventInterval) {
|
|
317
378
|
sendImpression(key, distance)
|
|
318
379
|
lastImpressionEventSent[key] = now
|
|
319
380
|
}
|
|
320
381
|
}
|
|
321
382
|
}
|
|
383
|
+
|
|
384
|
+
// Slow-drift exit detection — beacon disappeared without a region exit event
|
|
385
|
+
val beaconExitTimeout = 5_000L
|
|
386
|
+
for (key in lastProximityEventSent.keys.toList()) {
|
|
387
|
+
val lastSeen = beaconLastSeen[key]
|
|
388
|
+
if (lastSeen == null) { beaconLastSeen[key] = now; continue }
|
|
389
|
+
if (now - lastSeen >= beaconExitTimeout) {
|
|
390
|
+
cleanupBeacon(key)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
322
393
|
}
|
|
323
394
|
|
|
324
395
|
// ── Monitor notifier ──────────────────────────────────────────────────────
|
|
@@ -383,6 +454,24 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
383
454
|
.emit(name, payload)
|
|
384
455
|
}
|
|
385
456
|
|
|
457
|
+
private fun sendLocalNotification(title: String, body: String) {
|
|
458
|
+
val channelId = "spotny_sdk_channel"
|
|
459
|
+
val notifManager = reactContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
460
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
461
|
+
val channel = NotificationChannel(channelId, "Spotny SDK", NotificationManager.IMPORTANCE_DEFAULT)
|
|
462
|
+
notifManager.createNotificationChannel(channel)
|
|
463
|
+
}
|
|
464
|
+
val appIcon = reactContext.applicationInfo.icon.takeIf { it != 0 }
|
|
465
|
+
?: android.R.drawable.ic_dialog_info
|
|
466
|
+
val notification = NotificationCompat.Builder(reactContext, channelId)
|
|
467
|
+
.setSmallIcon(appIcon)
|
|
468
|
+
.setContentTitle(title)
|
|
469
|
+
.setContentText(body)
|
|
470
|
+
.setAutoCancel(true)
|
|
471
|
+
.build()
|
|
472
|
+
notifManager.notify(System.currentTimeMillis().toInt(), notification)
|
|
473
|
+
}
|
|
474
|
+
|
|
386
475
|
private fun logFile() = File(reactContext.filesDir, "spotny_beacon_debug.log")
|
|
387
476
|
|
|
388
477
|
private fun logToFile(message: String) {
|
|
@@ -397,24 +486,30 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
397
486
|
// ── State cleanup ─────────────────────────────────────────────────────────
|
|
398
487
|
|
|
399
488
|
private fun cleanupBeacon(key: String) {
|
|
400
|
-
//
|
|
401
|
-
|
|
489
|
+
// Only send exit if NEARBY was previously emitted for this beacon
|
|
490
|
+
val exitDistance = lastProximityDistance[key] ?: 0.01
|
|
491
|
+
if (lastProximityEventSent.containsKey(key)) sendProximity("PROXIMITY_EXIT", key, exitDistance)
|
|
402
492
|
activeCampaigns.remove(key)
|
|
403
493
|
lastProximityEventSent.remove(key)
|
|
404
494
|
lastProximityDistance.remove(key)
|
|
405
495
|
lastImpressionEventSent.remove(key)
|
|
406
|
-
// FIX #8: unified eventInProgress dict with suffixed keys
|
|
407
496
|
eventInProgress.remove("$key:proximity")
|
|
408
497
|
eventInProgress.remove("$key:impression")
|
|
409
498
|
campaignFetchInProgress.remove(key)
|
|
410
499
|
campaignFetched.remove(key)
|
|
411
500
|
campaignFetchCooldown.remove(key)
|
|
501
|
+
impressionDisabled.remove(key)
|
|
502
|
+
beaconLastSeen.remove(key)
|
|
412
503
|
Log.d(TAG, "Cleaned up $key")
|
|
413
504
|
}
|
|
414
505
|
|
|
415
506
|
private fun cleanupAllState() {
|
|
416
|
-
// FIX #3: use lastProximityEventSent — covers beacons even without campaign data
|
|
417
507
|
val keys = lastProximityEventSent.keys.toList()
|
|
508
|
+
if (keys.isEmpty()) {
|
|
509
|
+
// Killed-app wake: in-memory state lost — tell backend to close open sessions
|
|
510
|
+
sendResetEventStates()
|
|
511
|
+
return
|
|
512
|
+
}
|
|
418
513
|
keys.forEachIndexed { index, key ->
|
|
419
514
|
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
|
420
515
|
cleanupBeacon(key)
|
|
@@ -422,6 +517,13 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
422
517
|
}
|
|
423
518
|
}
|
|
424
519
|
|
|
520
|
+
private fun sendResetEventStates() {
|
|
521
|
+
if (sdkToken == null) return
|
|
522
|
+
post("$apiBasePath/reset-event-states", emptyMap()) { status, _ ->
|
|
523
|
+
Log.d(TAG, "reset-event-states → $status")
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
425
527
|
// ── Backend API ───────────────────────────────────────────────────────────
|
|
426
528
|
|
|
427
529
|
private fun isTokenExpired(): Boolean {
|
|
@@ -452,14 +554,14 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
452
554
|
readTimeout = 10_000
|
|
453
555
|
doOutput = true
|
|
454
556
|
}
|
|
455
|
-
conn.outputStream.use { it.write(buildJsonString(mapOf("
|
|
557
|
+
conn.outputStream.use { it.write(buildJsonString(mapOf("api_token" to credential, "api_key" to key, "identifier_id" to (identifierId ?: ""), "device_id" to getDeviceId())).toByteArray()) }
|
|
456
558
|
val status = conn.responseCode
|
|
457
559
|
val response = try { conn.inputStream.bufferedReader().readText() }
|
|
458
560
|
catch (_: Exception) { conn.errorStream?.bufferedReader()?.readText() ?: "" }
|
|
459
561
|
conn.disconnect()
|
|
460
562
|
if (status in 200..299) {
|
|
461
563
|
val data = (parseJsonObject(response)?.get("data") as? Map<*, *>)
|
|
462
|
-
val jwt = (data?.get("
|
|
564
|
+
val jwt = (data?.get("token") as? String)?.takeIf { it.isNotBlank() }
|
|
463
565
|
if (jwt != null) {
|
|
464
566
|
sdkToken = jwt
|
|
465
567
|
val expiresAt = (data?.get("expires_at") as? Number)?.toLong()
|
|
@@ -556,7 +658,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
556
658
|
return
|
|
557
659
|
}
|
|
558
660
|
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
559
|
-
campaign.sessionId?.let { payload["session_id"] = it }
|
|
560
661
|
} else {
|
|
561
662
|
activeCampaigns[key]?.campaignId?.let { payload["campaign_id"] = it }
|
|
562
663
|
}
|
|
@@ -564,6 +665,10 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
564
665
|
post(endpoint, payload) { status, _ ->
|
|
565
666
|
if (status in 200..299) {
|
|
566
667
|
Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
|
|
668
|
+
logToFile("$eventType beacon $key @ ${"%.2f".format(distance)}m")
|
|
669
|
+
} else if (status == 410 && isImpression) {
|
|
670
|
+
impressionDisabled[key] = true
|
|
671
|
+
Log.w(TAG, "IMPRESSION_HEARTBEAT session expired (410) — stopping heartbeats for $key")
|
|
567
672
|
} else if (status == 429) {
|
|
568
673
|
val penalty = System.currentTimeMillis() + 10_000L
|
|
569
674
|
if (isImpression) lastImpressionEventSent[key] = penalty
|
|
@@ -596,9 +701,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
|
596
701
|
if (dataObj != null) {
|
|
597
702
|
val campaignObj = dataObj["campaign"] as? Map<*, *>
|
|
598
703
|
val campaignId = (campaignObj?.get("id") as? Number)?.toInt()
|
|
599
|
-
val sessionId = campaignObj?.get("session_id") as? String
|
|
600
704
|
val inQueue = campaignObj?.get("inQueue") as? Boolean ?: false
|
|
601
|
-
activeCampaigns[key] = CampaignData(campaignId,
|
|
705
|
+
activeCampaigns[key] = CampaignData(campaignId, inQueue, major, minor)
|
|
602
706
|
campaignFetched[key] = true
|
|
603
707
|
Log.d(TAG, "Campaign fetched — campaignId=$campaignId inQueue=$inQueue")
|
|
604
708
|
}
|
|
@@ -894,6 +894,19 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
|
|
|
894
894
|
// FIX #9: nil lastDist already implies first detection — no separate isFirst needed
|
|
895
895
|
let lastDist = lastProximityDistance[key]
|
|
896
896
|
if lastDist == nil || (distance >= 1.0 && abs(distance - (lastDist ?? 0)) >= proximityDistanceThreshold) {
|
|
897
|
+
if lastDist == nil {
|
|
898
|
+
eventCallback?("onBeaconRangeEnter", [
|
|
899
|
+
"major": major,
|
|
900
|
+
"minor": minor,
|
|
901
|
+
"beaconId": key,
|
|
902
|
+
"distance": distance,
|
|
903
|
+
"region": "SpotnySDK_GeneralRegion"
|
|
904
|
+
])
|
|
905
|
+
sendLocalNotification(
|
|
906
|
+
title: "📡 Beacon Detected",
|
|
907
|
+
body: "Major: \(major), Minor: \(minor) — \(String(format: "%.1f", distance))m away"
|
|
908
|
+
)
|
|
909
|
+
}
|
|
897
910
|
sendProximity(eventType: "NEARBY", key: key, distance: distance)
|
|
898
911
|
lastProximityDistance[key] = distance
|
|
899
912
|
lastProximityEventSent[key] = now
|
|
@@ -901,8 +914,8 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
|
|
|
901
914
|
|
|
902
915
|
beaconLastSeen[key] = now
|
|
903
916
|
|
|
904
|
-
// Fetch campaign when distance <=
|
|
905
|
-
if distance <=
|
|
917
|
+
// Fetch campaign when distance <= 5m
|
|
918
|
+
if distance <= 5.0 && campaignFetched[key] != true {
|
|
906
919
|
fetchCampaign(key: key, major: major, minor: minor)
|
|
907
920
|
}
|
|
908
921
|
|